diff --git a/.cspell/other.txt b/.cspell/other.txt index a0b3134eed..99615fb1de 100644 --- a/.cspell/other.txt +++ b/.cspell/other.txt @@ -41,6 +41,7 @@ mycompanymyproductmylibrary MYSQLCONNECTOR MYSQLDATA NETRUNTIME +NLOG Npgsql NSERVICEBUS omnisharp @@ -58,9 +59,9 @@ protos RABBITMQ Serilog spdlog -srcs SQLCLIENT sqlserver +srcs STACKEXCHANGEREDIS TMPDIR tracesexporter diff --git a/CHANGELOG.md b/CHANGELOG.md index 70a60dc041..d119cd1c82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ This component adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.h ### Added +- 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 #### Dependency updates diff --git a/OpenTelemetry.AutoInstrumentation.sln b/OpenTelemetry.AutoInstrumentation.sln index e723a054e7..02bcebad1e 100644 --- a/OpenTelemetry.AutoInstrumentation.sln +++ b/OpenTelemetry.AutoInstrumentation.sln @@ -247,6 +247,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}" @@ -1535,6 +1537,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 @@ -1657,6 +1675,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} EndGlobalSection diff --git a/build/LibraryVersions.g.cs b/build/LibraryVersions.g.cs index 8cd4785459..7acacaa866 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"), + new("5.3.4"), + new("6.0.0"), + new("6.0.4"), + ] + }, { "TestApplication.MassTransit", [ diff --git a/docs/config.md b/docs/config.md index 5c66d05509..0084e76c82 100644 --- a/docs/config.md +++ b/docs/config.md @@ -207,6 +207,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** | ≥9.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 @@ -216,6 +217,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/log-trace-correlation.md b/docs/log-trace-correlation.md index 0be7785132..9bbd58f620 100644 --- a/docs/log-trace-correlation.md +++ b/docs/log-trace-correlation.md @@ -61,3 +61,26 @@ 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. \ No newline at end of file diff --git a/docs/log4net-bridge.md b/docs/log4net-bridge.md index 44ca5e2da8..092ad27136 100644 --- a/docs/log4net-bridge.md +++ b/docs/log4net-bridge.md @@ -48,5 +48,3 @@ In order for the bridge to be added, at least 1 other appender has to be configu Bridge should not be used when appenders are configured for both root and component loggers. Enabling a bridge in such scenario would result in bridge being appended to both appender collections, and logs duplication. - - diff --git a/docs/nlog-bridge.md b/docs/nlog-bridge.md new file mode 100644 index 0000000000..302768f3d5 --- /dev/null +++ b/docs/nlog-bridge.md @@ -0,0 +1,46 @@ +# `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 b2713fe956..97765a7426 100644 --- a/src/OpenTelemetry.AutoInstrumentation/.publicApi/net462/PublicAPI.Unshipped.txt +++ b/src/OpenTelemetry.AutoInstrumentation/.publicApi/net462/PublicAPI.Unshipped.txt @@ -1,3 +1,4 @@ +OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.Bridge.Integrations.LoggerIntegration OpenTelemetry.AutoInstrumentation.Instrumentations.RabbitMqLegacy.Integrations.AsyncDefaultBasicConsumerIntegration OpenTelemetry.AutoInstrumentation.Instrumentations.RabbitMqLegacy.Integrations.DefaultBasicConsumerIntegration OpenTelemetry.AutoInstrumentation.Instrumentations.RabbitMqLegacy.Integrations.ModelBaseBasicGetIntegration diff --git a/src/OpenTelemetry.AutoInstrumentation/.publicApi/net8.0/PublicAPI.Unshipped.txt b/src/OpenTelemetry.AutoInstrumentation/.publicApi/net8.0/PublicAPI.Unshipped.txt index b2713fe956..97765a7426 100644 --- a/src/OpenTelemetry.AutoInstrumentation/.publicApi/net8.0/PublicAPI.Unshipped.txt +++ b/src/OpenTelemetry.AutoInstrumentation/.publicApi/net8.0/PublicAPI.Unshipped.txt @@ -1,3 +1,4 @@ +OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.Bridge.Integrations.LoggerIntegration OpenTelemetry.AutoInstrumentation.Instrumentations.RabbitMqLegacy.Integrations.AsyncDefaultBasicConsumerIntegration OpenTelemetry.AutoInstrumentation.Instrumentations.RabbitMqLegacy.Integrations.DefaultBasicConsumerIntegration OpenTelemetry.AutoInstrumentation.Instrumentations.RabbitMqLegacy.Integrations.ModelBaseBasicGetIntegration diff --git a/src/OpenTelemetry.AutoInstrumentation/Configurations/ConfigurationKeys.cs b/src/OpenTelemetry.AutoInstrumentation/Configurations/ConfigurationKeys.cs index 6ace88c28c..5195121219 100644 --- a/src/OpenTelemetry.AutoInstrumentation/Configurations/ConfigurationKeys.cs +++ b/src/OpenTelemetry.AutoInstrumentation/Configurations/ConfigurationKeys.cs @@ -245,6 +245,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/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 0b15291aa5..9eedcdd1ff 100644 --- a/src/OpenTelemetry.AutoInstrumentation/Configurations/LogSettings.cs +++ b/src/OpenTelemetry.AutoInstrumentation/Configurations/LogSettings.cs @@ -33,6 +33,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. /// @@ -54,6 +59,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) ?? 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..2bfad1b5b3 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(37); // Traces var tracerSettings = Instrumentation.TracerSettings.Value; if (tracerSettings.TracesEnabled) @@ -101,6 +101,12 @@ 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", "Log", ["System.Void", "NLog.LogEventInfo"], 5, 0, 0, 6, 65535, 65535, AssemblyFullName, "OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.Bridge.Integrations.LoggerIntegration")); + } } // 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 c159ae2602..7a3db17089 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(40); // Traces var tracerSettings = Instrumentation.TracerSettings.Value; if (tracerSettings.TracesEnabled) @@ -104,6 +104,12 @@ private static NativeCallTargetDefinition[] GetDefinitionsArray() { nativeCallTargetDefinitions.Add(new("Microsoft.Extensions.Logging", "Microsoft.Extensions.Logging.LoggingBuilder", ".ctor", ["System.Void", "Microsoft.Extensions.DependencyInjection.IServiceCollection"], 9, 0, 0, 9, 65535, 65535, AssemblyFullName, "OpenTelemetry.AutoInstrumentation.Instrumentations.Logger.LoggingBuilderIntegration")); } + + // NLog + if (logSettings.EnabledInstrumentations.Contains(LogInstrumentation.NLog)) + { + nativeCallTargetDefinitions.Add(new("NLog", "NLog.Logger", "Log", ["System.Void", "NLog.LogEventInfo"], 5, 0, 0, 6, 65535, 65535, AssemblyFullName, "OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.Bridge.Integrations.LoggerIntegration")); + } } // Metrics diff --git a/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/AutoInjection/NLogAutoInjector.cs b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/AutoInjection/NLogAutoInjector.cs new file mode 100644 index 0000000000..080a36539d --- /dev/null +++ b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/AutoInjection/NLogAutoInjector.cs @@ -0,0 +1,80 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Reflection; +using OpenTelemetry.AutoInstrumentation.DuckTyping; +using OpenTelemetry.AutoInstrumentation.Logging; + +namespace OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.AutoInjection; + +internal static class NLogAutoInjector +{ + private static readonly IOtelLogger Logger = OtelLogging.GetLogger(); + private static int _attempted; + + public static void EnsureConfigured() + { + if (Interlocked.Exchange(ref _attempted, 1) != 0) + { + return; + } + + try + { + var nlogLogManager = Type.GetType("NLog.LogManager, NLog"); + if (nlogLogManager is null) + { + return; + } + + var configurationProperty = nlogLogManager.GetProperty("Configuration", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); + if (configurationProperty is null) + { + return; + } + + var configuration = configurationProperty.GetValue(null); + if (configuration is null) + { + var configurationType = Type.GetType("NLog.Config.LoggingConfiguration, NLog"); + configuration = Activator.CreateInstance(configurationType!); + configurationProperty.SetValue(null, configuration); + } + + // Create the OpenTelemetry target instance and wrap it in a duck proxy + var otelTarget = new OpenTelemetryTarget(); + var targetType = Type.GetType("NLog.Targets.TargetWithContext, NLog", false); + if (targetType is null) + { + Logger.Warning("NLog auto-injection skipped: TargetWithContext type not found."); + return; + } + + var targetProxy = otelTarget.DuckImplement(targetType); + + // Add target to configuration + var addTargetMethod = configuration!.GetType().GetMethod("AddTarget", BindingFlags.Instance | BindingFlags.Public); + addTargetMethod?.Invoke(configuration, new object?[] { "otlp", targetProxy }); + + // Create rule: * -> otlp (minlevel: Trace) + var loggingRuleType = Type.GetType("NLog.Config.LoggingRule, NLog"); + var logLevelType = Type.GetType("NLog.LogLevel, NLog"); + var traceLevel = logLevelType?.GetProperty("Trace", BindingFlags.Static | BindingFlags.Public)?.GetValue(null); + var rule = Activator.CreateInstance(loggingRuleType!, new object?[] { "*", traceLevel, targetProxy }); + + var loggingRulesProp = configuration.GetType().GetProperty("LoggingRules", BindingFlags.Instance | BindingFlags.Public); + var rulesList = loggingRulesProp?.GetValue(configuration) as System.Collections.IList; + rulesList?.Add(rule); + + // Apply configuration + var reconfigMethod = nlogLogManager.GetMethod("ReconfigExistingLoggers", BindingFlags.Static | BindingFlags.Public); + reconfigMethod?.Invoke(null, null); + + Logger.Information("NLog OpenTelemetryTarget auto-injected."); + } + catch (Exception ex) + { + Logger.Warning(ex, "NLog OpenTelemetryTarget auto-injection failed."); + } + } +} diff --git a/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/Bridge/Integrations/LoggerIntegration.cs b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/Bridge/Integrations/LoggerIntegration.cs new file mode 100644 index 0000000000..1555430705 --- /dev/null +++ b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/Bridge/Integrations/LoggerIntegration.cs @@ -0,0 +1,109 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics; +using System.Reflection; +using OpenTelemetry.AutoInstrumentation.CallTarget; +using OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.AutoInjection; +using OpenTelemetry.AutoInstrumentation.Logging; +#if NET +using OpenTelemetry.AutoInstrumentation.Logger; +#endif + +namespace OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.Bridge.Integrations; + +/// +/// NLog Logger integration that hooks into the actual logging process. +/// This integration intercepts NLog's Logger.Log method calls to automatically +/// capture log events and forward them to OpenTelemetry when the NLog bridge is enabled. +/// +/// The integration targets NLog.Logger.Log method which is the core method called +/// for all logging operations, allowing us to capture events without modifying configuration. +/// +[InstrumentMethod( +assemblyName: "NLog", +typeName: "NLog.Logger", +methodName: "Log", +returnTypeName: ClrNames.Void, +parameterTypeNames: new[] { "NLog.LogEventInfo" }, +minimumVersion: "5.0.0", +maximumVersion: "6.*.*", +integrationName: "NLog", +type: InstrumentationType.Log)] +public static class LoggerIntegration +{ +#if NET + private static readonly IOtelLogger Logger = OtelLogging.GetLogger(); + private static int _warningLogged; +#endif + + /// + /// Intercepts NLog's Logger.Log method calls to capture log events. + /// This method is called before the original Log method executes, + /// allowing us to capture and forward log events to OpenTelemetry. + /// + /// The type of the logger instance. + /// The NLog Logger instance. + /// The NLog LogEventInfo being logged. + /// A CallTargetState (unused in this case). + internal static CallTargetState OnMethodBegin(TTarget instance, ILoggingEvent logEvent) + { +#if NET + // Check if ILogger bridge has been initialized and warn if so + // This prevents conflicts between different logging bridges + if (LoggerInitializer.IsInitializedAtLeastOnce) + { + if (Interlocked.Exchange(ref _warningLogged, 1) != default) + { + return CallTargetState.GetDefault(); + } + + Logger.Warning("Disabling NLog bridge due to ILogger bridge initialization."); + return CallTargetState.GetDefault(); + } +#endif + + // Only process the log event if the NLog bridge is enabled + if (Instrumentation.LogSettings.Value.EnableNLogBridge) + { + // Ensure the OpenTelemetry NLog target is configured (zero-config path) + NLogAutoInjector.EnsureConfigured(); + + // Inject trace context into NLog GlobalDiagnosticsContext for current destination outputs + TrySetTraceContext(Activity.Current); + } + + // Return default state - we don't need to track anything between begin/end + return CallTargetState.GetDefault(); + } + + private static void TrySetTraceContext(Activity? activity) + { + try + { + var gdcType = Type.GetType("NLog.GlobalDiagnosticsContext, NLog"); + if (gdcType is null) + { + return; + } + + var setMethod = gdcType.GetMethod("Set", BindingFlags.Public | BindingFlags.Static, null, [typeof(string), typeof(string)], null); + if (setMethod is null) + { + return; + } + + string spanId = activity?.SpanId.ToString() ?? "(null)"; + string traceId = activity?.TraceId.ToString() ?? "(null)"; + string traceFlags = activity is null ? "(null)" : ((byte)activity.ActivityTraceFlags).ToString("x2"); + + setMethod.Invoke(null, new object[] { "span_id", spanId }); + setMethod.Invoke(null, new object[] { "trace_id", traceId }); + setMethod.Invoke(null, new object[] { "trace_flags", traceFlags }); + } + catch + { + // best-effort only + } + } +} 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..216e7dfd33 --- /dev/null +++ b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/Bridge/OpenTelemetryLogHelpers.cs @@ -0,0 +1,320 @@ +// 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, new[] { 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( + new[] { 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)!; + var assignInstanceVar = Expression.Assign(instanceVar, Expression.New(constructorInfo)); + + var addAttributeMethodInfo = logRecordAttributesListType.GetMethod("Add", new[] { typeof(string), typeof(object) })!; + + var expressions = new List { assignInstanceVar }; + + // Add exception as an attribute if present + var addExceptionExpression = Expression.IfThen( + Expression.NotEqual(exception, Expression.Constant(null)), + Expression.Call(instanceVar, addAttributeMethodInfo, Expression.Constant("exception"), exception)); + expressions.Add(addExceptionExpression); + + // 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( + new[] { 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 Expression 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( + new[] { 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 Expression 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( + typeof(string).GetMethod("Concat", new[] { typeof(string), typeof(string) })!, + Expression.Constant("arg_"), + 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( + new[] { 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, new[] { logRecordDataType.MakeByRefType(), logRecordAttributesListType.MakeByRefType() }, null)!; + + // Build the complete expression that creates the log record, creates attributes, and emits the log + var completeExpression = Expression.Block( + Expression.Call( + Expression.Convert(loggerInstance, loggerType), + emitLogRecordMethod, + logRecordExpression, + attributesExpression)); + + // 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..d62aff7a02 --- /dev/null +++ b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/Bridge/OpenTelemetryNLogConverter.cs @@ -0,0 +1,199 @@ +// 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; +#if NET +using OpenTelemetry.AutoInstrumentation.Logger; +#endif +using OpenTelemetry.AutoInstrumentation.Logging; +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 = new[] { "NLog.LogEventInfo, NLog" })] + public void WriteLogEvent(ILoggingEvent loggingEvent) + { + if (Sdk.SuppressInstrumentation || loggingEvent.Level.Ordinal == OffOrdinal) + { + 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 IEnumerable>? GetProperties(ILoggingEvent loggingEvent) + { + try + { + var properties = loggingEvent.GetProperties(); + return properties == null ? null : GetFilteredProperties(properties); + } + catch (Exception) + { + return null; + } + } + + private static IEnumerable> GetFilteredProperties(IDictionary properties) + { + foreach (var propertyKey in properties.Keys) + { + if (propertyKey is not string key) + { + continue; + } + + if (key.StartsWith("NLog.") || + key.StartsWith("nlog:") || + key == LogsTraceContextInjectionConstants.SpanIdPropertyName || + key == LogsTraceContextInjectionConstants.TraceIdPropertyName || + key == LogsTraceContextInjectionConstants.TraceFlagsPropertyName) + { + continue; + } + + yield return new KeyValuePair(key, properties[key]); + } + } + + private static Func? CreateGetLoggerDelegate(LoggerProvider loggerProvider) + { + try + { + var methodInfo = typeof(LoggerProvider) + .GetMethod("GetLogger", BindingFlags.NonPublic | BindingFlags.Instance, null, new[] { typeof(string) }, null)!; + return (Func)methodInfo.CreateDelegate(typeof(Func), loggerProvider); + } + 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..be86db6383 --- /dev/null +++ b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/ILoggingEvent.cs @@ -0,0 +1,112 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using OpenTelemetry.AutoInstrumentation.DuckTyping; +#pragma warning disable CS0649 // Field is never assigned to, and will always have its default value + +namespace OpenTelemetry.AutoInstrumentation.Instrumentations.NLog; + +/// +/// Duck typing interface that wraps NLog's LogEventInfo class. +/// This interface 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 +/// +internal interface ILoggingEvent +{ + /// + /// Gets the logging level of the log event. + /// Maps to NLog's LogLevel property. + /// + public LoggingLevel Level { get; } + + /// + /// Gets the name of the logger that created the log event. + /// Maps to NLog's LoggerName property. + /// + public string? LoggerName { get; } + + /// + /// Gets the formatted log message. + /// Maps to NLog's FormattedMessage property. + /// + public string? FormattedMessage { get; } + + /// + /// Gets the exception associated with the log event, if any. + /// Maps to NLog's Exception property. + /// + public Exception? Exception { get; } + + /// + /// Gets the timestamp when the log event was created. + /// Maps to NLog's TimeStamp property. + /// + public DateTime TimeStamp { get; } + + /// + /// Gets the message object before formatting. + /// Maps to NLog's Message property. + /// + public object? Message { get; } + + /// + /// Gets the parameters for the log message. + /// Maps to NLog's Parameters property. + /// + public object?[]? Parameters { get; } + + /// + /// Gets the properties collection for custom properties. + /// Used for injecting trace context and storing additional metadata. + /// Maps to NLog's Properties property. + /// + public IDictionary? Properties { get; } + + /// + /// Gets the context properties dictionary. + /// Maps to NLog's Properties property with read access. + /// + public IDictionary? GetProperties(); +} + +/// +/// Duck typing interface for NLog's message template structure. +/// This represents structured logging information when using message templates. +/// +internal interface IMessageTemplateParameters : IDuckType +{ + /// + /// Gets the message template format string. + /// + public string? MessageTemplate { get; } + + /// + /// Gets the parameters for the message template. + /// + public object?[]? Parameters { get; } +} + +/// +/// 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/OpenTelemetryTarget.cs b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/OpenTelemetryTarget.cs new file mode 100644 index 0000000000..c532b42c8d --- /dev/null +++ b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/OpenTelemetryTarget.cs @@ -0,0 +1,183 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Reflection; +using OpenTelemetry.AutoInstrumentation.Configurations; +using OpenTelemetry.AutoInstrumentation.DuckTyping; +using OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.Bridge; +using OpenTelemetry.Logs; + +namespace OpenTelemetry.AutoInstrumentation.Instrumentations.NLog; + +/// +/// OpenTelemetry Target for NLog using duck typing to avoid direct NLog assembly references. +/// This target is designed to be used through auto-injection and duck-typed proxies. +/// +internal sealed class OpenTelemetryTarget +{ + private static readonly ConcurrentDictionary LoggerCache = new(StringComparer.Ordinal); + + private static LoggerProvider? _loggerProvider; + private static Func? _getLoggerFactory; + + [DuckReverseMethod] + public string Name { get; set; } = nameof(OpenTelemetryTarget); + + [DuckReverseMethod] + public void InitializeTarget() + { + if (_loggerProvider != null) + { + return; + } + + var createLoggerProviderBuilderMethod = typeof(Sdk).GetMethod("CreateLoggerProviderBuilder", BindingFlags.Static | BindingFlags.NonPublic)!; + var loggerProviderBuilder = (LoggerProviderBuilder)createLoggerProviderBuilderMethod.Invoke(null, null)!; + + loggerProviderBuilder = loggerProviderBuilder + .SetResourceBuilder(ResourceConfigurator.CreateResourceBuilder(Instrumentation.ResourceSettings.Value)); + + loggerProviderBuilder = loggerProviderBuilder.AddOtlpExporter(); + + _loggerProvider = loggerProviderBuilder.Build(); + _getLoggerFactory = CreateGetLoggerDelegate(_loggerProvider); + } + + [DuckReverseMethod(ParameterTypeNames = new[] { "NLog.LogEventInfo, NLog" })] + public void Write(ILoggingEvent? logEvent) + { + if (logEvent is null || _loggerProvider is null) + { + return; + } + + if (Sdk.SuppressInstrumentation) + { + return; + } + + var logger = GetOrCreateLogger(logEvent.LoggerName); + if (logger is null) + { + return; + } + + var properties = GetLogEventProperties(logEvent); + + // Use formatted message if available, otherwise use raw message + var body = logEvent.FormattedMessage ?? logEvent.Message?.ToString(); + + var severityText = logEvent.Level.Name; + var severityNumber = MapLogLevelToSeverity(logEvent.Level.Ordinal); + + // Use Activity.Current for trace context + var current = Activity.Current; + + // Include event parameters if available + var args = logEvent.Parameters is object[] p ? p : null; + + OpenTelemetryLogHelpers.LogEmitter?.Invoke( + logger, + body, + logEvent.TimeStamp, + severityText, + severityNumber, + logEvent.Exception, + properties, + current, + args, + logEvent.FormattedMessage); + } + + private static int MapLogLevelToSeverity(int levelOrdinal) + { + // Map NLog ordinals 0..5 to OTEL severity 1..24 approximate buckets + return levelOrdinal switch + { + 0 => 1, // Trace + 1 => 5, // Debug + 2 => 9, // Info + 3 => 13, // Warn + 4 => 17, // Error + 5 => 21, // Fatal + 6 => 1, // Off + _ => 9 + }; + } + + private static Func? CreateGetLoggerDelegate(LoggerProvider loggerProvider) + { + try + { + var methodInfo = typeof(LoggerProvider) + .GetMethod("GetLogger", BindingFlags.NonPublic | BindingFlags.Instance, null, new[] { typeof(string) }, null)!; + return (Func)methodInfo.CreateDelegate(typeof(Func), loggerProvider); + } + catch + { + return null; + } + } + + private static IEnumerable>? GetLogEventProperties(ILoggingEvent logEvent) + { + try + { + var properties = logEvent.GetProperties(); + return properties == null ? null : GetFilteredProperties(properties); + } + catch (Exception) + { + return null; + } + } + + private static IEnumerable> GetFilteredProperties(System.Collections.IDictionary properties) + { + foreach (var propertyKey in properties.Keys) + { + if (propertyKey is not string key) + { + continue; + } + + if (key.StartsWith("NLog.") || + key.StartsWith("nlog:") || + key == TraceContextInjection.LogsTraceContextInjectionConstants.SpanIdPropertyName || + key == TraceContextInjection.LogsTraceContextInjectionConstants.TraceIdPropertyName || + key == TraceContextInjection.LogsTraceContextInjectionConstants.TraceFlagsPropertyName) + { + continue; + } + + yield return new KeyValuePair(key, properties[key]); + } + } + + private object? GetOrCreateLogger(string? loggerName) + { + var key = loggerName ?? string.Empty; + if (LoggerCache.TryGetValue(key, out var logger)) + { + return logger; + } + + var factory = _getLoggerFactory; + if (factory is null) + { + return null; + } + + logger = factory(loggerName); + if (logger is not null) + { + LoggerCache[key] = logger; + } + + return logger; + } +} diff --git a/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/README.md b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/README.md new file mode 100644 index 0000000000..1bf20b4de8 --- /dev/null +++ b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/README.md @@ -0,0 +1,198 @@ +# NLog OpenTelemetry Auto-Instrumentation + +This directory contains the NLog instrumentation for OpenTelemetry .NET Auto-Instrumentation. This instrumentation provides automatic zero-config injection for bridging NLog logging events to OpenTelemetry using duck typing. + +## Overview + +The NLog instrumentation offers automatic integration through: +1. **Zero-Config Auto-Injection**: Automatically injects duck-typed `OpenTelemetryTarget` into existing NLog configurations +2. **Duck Typing Integration**: Uses `[DuckReverseMethod]` to avoid direct NLog assembly references +3. **Log Event Bridging**: Converting NLog log events to OpenTelemetry log records +4. **Structured Logging Support**: Leveraging NLog's layout abilities for enrichment +5. **Trace Context Integration**: Automatically including trace context in log records +6. **Custom Properties**: Forwarding custom properties while filtering internal NLog properties + +**Note**: XML configuration via `nlog.config` is not supported. The target works exclusively through auto-injection and relies on OpenTelemetry environment variables for configuration. + +## Architecture + +### Zero-Config Path (Auto-Injection) +``` +NLog Logger.Log() Call + ↓ +LoggerIntegration (CallTarget) + ↓ +NLogAutoInjector.EnsureConfigured() + ↓ +OpenTelemetryTarget → NLog Configuration + ↓ +OpenTelemetryNLogConverter + ↓ +OpenTelemetry LogRecord + ↓ +OTLP Exporters +``` + +### Auto-Injection Path (Duck Typing) +``` +NLog Logger.Log() Call + ↓ +LoggerIntegration (CallTarget) + ↓ +NLogAutoInjector.EnsureConfigured() + ↓ +OpenTelemetryTarget (Duck-typed proxy) → NLog Configuration + ↓ +OpenTelemetry LogRecord + ↓ +OTLP Exporters +``` + +## Components + +### Core Components + +#### Auto-Instrumentation Components +- **`ILoggingEvent.cs`**: Duck typing interface for NLog's LogEventInfo +- **`OpenTelemetryNLogConverter.cs`**: Internal converter that transforms NLog events to OpenTelemetry log records +- **`OpenTelemetryLogHelpers.cs`**: Helper for creating OpenTelemetry log records via expression trees +- **`NLogAutoInjector.cs`**: Handles programmatic injection of OpenTelemetryTarget into NLog configuration + +#### Duck-Typed NLog Target +- **`OpenTelemetryTarget.cs`**: Duck-typed NLog target using `[DuckReverseMethod]` to avoid direct NLog assembly references + +### Integration + +- **`LoggerIntegration.cs`**: CallTarget integration that intercepts `NLog.Logger.Log` to trigger auto-injection and GDC trace context + +### Trace Context + +- **`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 auto-injection 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 target automatically: +- Uses formatted message if available, otherwise raw message +- Includes event parameters when present +- Captures trace context from `Activity.Current` +- Forwards custom properties while filtering internal NLog properties + +## Supported Versions + +- **NLog**: 5.0.0+ (required for Layout<T> typed layout support and .NET build-trimming) +- **.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 | Trace | 1 | + +## Duck Typing + +The instrumentation uses duck typing to interact with NLog without requiring direct references: + +- **`ILoggingEvent`**: Maps to `NLog.LogEventInfo` +- **`LoggingLevel`**: Maps to `NLog.LogLevel` +- **`IMessageTemplateParameters`**: Maps to structured logging parameters + +## Property Filtering + +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`) + +## Performance Considerations + +- **Logger Caching**: OpenTelemetry loggers are cached (up to 100) to avoid recreation overhead +- **Lazy Initialization**: Components are initialized only when needed +- **Minimal Overhead**: The target is injected once during configuration loading + +## 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 when other logging bridges are active + +## Testing + +Tests are located in `test/OpenTelemetry.AutoInstrumentation.Tests/NLogTests.cs` and cover: +- Level mapping verification +- Edge case handling (invalid levels, off level) +- Custom level support +- Range-based mapping logic + +## Integration Testing + +A complete test application is available at `test/test-applications/integrations/TestApplication.NLogBridge/` that demonstrates: +- Direct NLog usage +- Microsoft.Extensions.Logging integration via custom provider +- Structured logging scenarios +- Exception logging +- Custom properties +- Trace context propagation +- Both auto-injection and manual target configuration paths + +## Troubleshooting + +### Common Issues + +1. **Bridge Not Working** + - Verify `OTEL_DOTNET_AUTO_LOGS_ENABLE_NLOG_BRIDGE=true` + - Check that NLog version is supported + - Ensure auto-instrumentation is properly loaded + +2. **Missing Properties** + - Check NLog configuration for property capture + - Verify properties don't start with filtered prefixes + +3. **Performance Impact** + - Monitor logger cache efficiency + - Consider adjusting cache size if many dynamic logger names are used + +### Debug Information + +Enable debug logging to see: +- Target injection success/failure +- Logger creation and caching +- Property filtering decisions + +## 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 \ No newline at end of file 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 3fe673b2a6..e4188dc8dc 100644 --- a/test/Directory.Packages.props +++ b/test/Directory.Packages.props @@ -35,6 +35,7 @@ + diff --git a/test/IntegrationTests/LibraryVersions.g.cs b/test/IntegrationTests/LibraryVersions.g.cs index aa72fb9bb4..06fad2b87f 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.4", #endif ]; return theoryData; @@ -403,6 +421,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..3e9adfd5c5 --- /dev/null +++ b/test/IntegrationTests/NLogBridgeTests.cs @@ -0,0 +1,189 @@ +// 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 } && + VerifyAttributes(logRecord) && + logRecord.Attributes.Count == 4, + "Expected Info record."); + + // Logged with exception + collector.Expect( + logRecord => + VerifyBody(logRecord, "Exception occured") && + logRecord is { SeverityText: "Error", SeverityNumber: SeverityNumber.Error } && + VerifyExceptionAttributes(logRecord) && + logRecord.Attributes.Count == 4, + "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 + collector.Expect( + logRecord => + VerifyBody(logRecord, "{0}, {1} at {2:t}!") && + VerifyTraceContext(logRecord) && + logRecord is { SeverityText: "Info", 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); + + 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); + + // 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{2}\:\d{2}\! span_id=[a-f0-9]{16} trace_id=[a-f0-9]{32} trace_flags=01"); + var output = standardOutput; + Assert.Matches(regex, output); + Assert.Contains("ERROR TestApplication.NLogBridge.Program - Exception occured span_id=(null) trace_id=(null) trace_flags=(null)", output); + } + + private static bool VerifyAttributes(LogRecord logRecord) + { + var firstArgAttribute = logRecord.Attributes.SingleOrDefault(value => value.Key == "0"); + var secondArgAttribute = logRecord.Attributes.SingleOrDefault(value => value.Key == "1"); + var customAttribute = logRecord.Attributes.SingleOrDefault(value => value.Key == "test_key"); + return firstArgAttribute?.Value.StringValue == "Hello" && + secondArgAttribute?.Value.StringValue == "world" && + logRecord.Attributes.Count(value => value.Key == "2") == 1 && + customAttribute?.Value.StringValue == "test_value"; + } + + private static bool VerifyTraceContext(LogRecord logRecord) + { + return logRecord.TraceId != ByteString.Empty && + logRecord.SpanId != ByteString.Empty && + logRecord.Flags != 0; + } + + private static void AssertStandardOutputExpectations(string standardOutput) + { + Assert.Contains("INFO TestApplication.NLogBridge.Program - Hello, world at", 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/SettingsTests.cs b/test/OpenTelemetry.AutoInstrumentation.Tests/Configurations/SettingsTests.cs index 1b5ebd8fab..b5cdf3c76c 100644 --- a/test/OpenTelemetry.AutoInstrumentation.Tests/Configurations/SettingsTests.cs +++ b/test/OpenTelemetry.AutoInstrumentation.Tests/Configurations/SettingsTests.cs @@ -348,6 +348,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/NLogLogger.cs b/test/test-applications/integrations/TestApplication.NLogBridge/NLogLogger.cs new file mode 100644 index 0000000000..73724d8f19 --- /dev/null +++ b/test/test-applications/integrations/TestApplication.NLogBridge/NLogLogger.cs @@ -0,0 +1,51 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using Microsoft.Extensions.Logging; +using NLog; + +namespace TestApplication.NLogBridge; + +internal class NLogLogger : Microsoft.Extensions.Logging.ILogger +{ + private readonly NLog.ILogger _log; + + public NLogLogger(string categoryName) + { + _log = LogManager.GetLogger(categoryName); + } + + public void Log(Microsoft.Extensions.Logging.LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + switch (logLevel) + { + case Microsoft.Extensions.Logging.LogLevel.Information: + _log.Info(formatter(state, exception)); + break; + case Microsoft.Extensions.Logging.LogLevel.Error: + _log.Error(exception, formatter(state, exception)); + break; + default: + throw new ArgumentOutOfRangeException(nameof(logLevel), logLevel, null); + } + } + + public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) + { + return logLevel switch + { + Microsoft.Extensions.Logging.LogLevel.Critical => _log.IsFatalEnabled, + Microsoft.Extensions.Logging.LogLevel.Debug or Microsoft.Extensions.Logging.LogLevel.Trace => _log.IsDebugEnabled, + Microsoft.Extensions.Logging.LogLevel.Error => _log.IsErrorEnabled, + Microsoft.Extensions.Logging.LogLevel.Information => _log.IsInfoEnabled, + Microsoft.Extensions.Logging.LogLevel.Warning => _log.IsWarnEnabled, + _ => throw new ArgumentOutOfRangeException(nameof(logLevel), logLevel, null) + }; + } + + public IDisposable? BeginScope(TState state) + where TState : notnull + { + return null; + } +} diff --git a/test/test-applications/integrations/TestApplication.NLogBridge/NLogLoggerProvider.cs b/test/test-applications/integrations/TestApplication.NLogBridge/NLogLoggerProvider.cs new file mode 100644 index 0000000000..7f88b907a2 --- /dev/null +++ b/test/test-applications/integrations/TestApplication.NLogBridge/NLogLoggerProvider.cs @@ -0,0 +1,18 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using Microsoft.Extensions.Logging; + +namespace TestApplication.NLogBridge; + +public class NLogLoggerProvider : ILoggerProvider +{ + public Microsoft.Extensions.Logging.ILogger CreateLogger(string categoryName) + { + return new NLogLogger(categoryName); + } + + public void Dispose() + { + } +} 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..633d3bf1f8 --- /dev/null +++ b/test/test-applications/integrations/TestApplication.NLogBridge/Program.cs @@ -0,0 +1,84 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using NLog; + +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) + { + // Set global context property for testing + GlobalDiagnosticsContext.Set("test_key", "test_value"); + + 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) + { + using var activity = Source.StartActivity("ManuallyStarted"); + action(); + } + + private static void LogUsingNLogDirectly() + { + var log = LogManager.GetLogger(typeof(Program).FullName!); + + LogInsideActiveScope(() => log.Info("{0}, {1} at {2:t}!", "Hello", "world", DateTime.Now)); + + var (message, ex) = GetException(); + log.Error(ex, message); + } + + 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..229575f924 --- /dev/null +++ b/test/test-applications/integrations/TestApplication.NLogBridge/TestApplication.NLogBridge.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + \ 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..2976cdc7c7 --- /dev/null +++ b/test/test-applications/integrations/TestApplication.NLogBridge/nlog.config @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tools/LibraryVersionsGenerator/PackageVersionDefinitions.cs b/tools/LibraryVersionsGenerator/PackageVersionDefinitions.cs index 06a04fa2f8..d51aafed1b 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"), + new("5.3.4"), + new("6.0.0"), + new("*") + } + }, + new() { IntegrationName = "MassTransit", NugetPackageName = "MassTransit",