diff --git a/sdk/core/Azure.Core/src/Shared/EventSourceEvent.cs b/sdk/core/Azure.Core/src/Shared/EventSourceEvent.cs new file mode 100644 index 0000000000000..94100e913c897 --- /dev/null +++ b/sdk/core/Azure.Core/src/Shared/EventSourceEvent.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.Tracing; + +#nullable enable + +namespace Azure.Core.Shared +{ + /// + /// Wraps into simplifying iterating over + /// payload properties and providing them to logging libraries in a structured way. + /// + internal readonly struct EventSourceEvent : IReadOnlyList> + { + /// + /// Gets underlying EventSource event. + /// + public EventWrittenEventArgs EventData { get; } + + public EventSourceEvent(EventWrittenEventArgs eventData) + { + EventData = eventData; + } + + /// + public IEnumerator> GetEnumerator() + { + if (EventData.PayloadNames == null || EventData.Payload == null) + { + yield break; + } + + for (int i = 0; i < Count; i++) + { + yield return new KeyValuePair(EventData.PayloadNames[i], EventData.Payload[i]); + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// + /// Returns the count of payload properties in the Eventsource event. + /// + public int Count => EventData.PayloadNames?.Count ?? 0; + + /// + /// Formats EventSource event as a string including all payload properties. + /// + public string Format() + { + return EventSourceEventFormatting.Format(EventData); + } + + /// + public KeyValuePair this[int index] + { + get + { + if (EventData.PayloadNames == null || EventData.Payload == null || index >= EventData.PayloadNames.Count || index < 0) + { + throw new IndexOutOfRangeException("Index was out of range."); + } + + return new KeyValuePair(EventData.PayloadNames[index], EventData.Payload[index]); + } + } + } +} diff --git a/sdk/extensions/Microsoft.Extensions.Azure/src/Internal/AzureEventSourceLogForwarder.cs b/sdk/extensions/Microsoft.Extensions.Azure/src/AzureEventSourceLogForwarder.cs similarity index 73% rename from sdk/extensions/Microsoft.Extensions.Azure/src/Internal/AzureEventSourceLogForwarder.cs rename to sdk/extensions/Microsoft.Extensions.Azure/src/AzureEventSourceLogForwarder.cs index 1f9cc9a3f8b6c..8aaefe4141a03 100644 --- a/sdk/extensions/Microsoft.Extensions.Azure/src/Internal/AzureEventSourceLogForwarder.cs +++ b/sdk/extensions/Microsoft.Extensions.Azure/src/AzureEventSourceLogForwarder.cs @@ -2,9 +2,7 @@ // Licensed under the MIT License. using System; -using System.Collections; using System.Collections.Concurrent; -using System.Collections.Generic; using System.Diagnostics.Tracing; using Azure.Core.Diagnostics; using Azure.Core.Shared; @@ -85,36 +83,6 @@ private static LogLevel MapLevel(EventLevel level) } } - private static string FormatMessage(EventSourceEvent eventSourceEvent, Exception exception) - { - return EventSourceEventFormatting.Format(eventSourceEvent.EventData); - } - - private readonly struct EventSourceEvent: IReadOnlyList> - { - public EventWrittenEventArgs EventData { get; } - - public EventSourceEvent(EventWrittenEventArgs eventData) - { - EventData = eventData; - } - - public IEnumerator> GetEnumerator() - { - for (int i = 0; i < Count; i++) - { - yield return new KeyValuePair(EventData.PayloadNames[i], EventData.Payload[i]); - } - } - - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } - - public int Count => EventData.PayloadNames.Count; - - public KeyValuePair this[int index] => new KeyValuePair(EventData.PayloadNames[index], EventData.Payload[index]); - } + private static string FormatMessage(EventSourceEvent eventSourceEvent, Exception _) => eventSourceEvent.Format(); } } diff --git a/sdk/extensions/Microsoft.Extensions.Azure/src/Microsoft.Extensions.Azure.csproj b/sdk/extensions/Microsoft.Extensions.Azure/src/Microsoft.Extensions.Azure.csproj index 582b9b87a6106..6b1cac6a63a1b 100644 --- a/sdk/extensions/Microsoft.Extensions.Azure/src/Microsoft.Extensions.Azure.csproj +++ b/sdk/extensions/Microsoft.Extensions.Azure/src/Microsoft.Extensions.Azure.csproj @@ -28,6 +28,7 @@ + diff --git a/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/src/Azure.Monitor.OpenTelemetry.AspNetCore.csproj b/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/src/Azure.Monitor.OpenTelemetry.AspNetCore.csproj index 362958bcedd19..f23e8e60b3a97 100644 --- a/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/src/Azure.Monitor.OpenTelemetry.AspNetCore.csproj +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/src/Azure.Monitor.OpenTelemetry.AspNetCore.csproj @@ -1,4 +1,4 @@ - + An OpenTelemetry .NET distro that exports to Azure Monitor AzureMonitor OpenTelemetry ASP.NET Core Distro @@ -30,10 +30,12 @@ - - + + + + - + diff --git a/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/src/AzureMonitorAspNetCoreEventSource.cs b/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/src/AzureMonitorAspNetCoreEventSource.cs index 00e375019bd50..bac1d1f7d0542 100644 --- a/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/src/AzureMonitorAspNetCoreEventSource.cs +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/src/AzureMonitorAspNetCoreEventSource.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System; using System.Diagnostics.Tracing; using System.Runtime.CompilerServices; using Azure.Monitor.OpenTelemetry.Exporter.Internals; @@ -60,6 +59,15 @@ public void GetEnvironmentVariableFailed(string envVarName, Exception ex) } } + [NonEvent] + public void MapLogLevelFailed(EventLevel level) + { + if (IsEnabled(EventLevel.Warning)) + { + MapLogLevelFailed(level.ToString()); + } + } + [Event(1, Message = "Failed to configure AzureMonitorOptions using the connection string from environment variables due to an exception: {0}", Level = EventLevel.Error)] public void ConfigureFailed(string exceptionMessage) => WriteEvent(1, exceptionMessage); @@ -74,5 +82,11 @@ public void GetEnvironmentVariableFailed(string envVarName, Exception ex) [Event(5, Message = "Failed to Read environment variable {0}, exception: {1}", Level = EventLevel.Error)] public void GetEnvironmentVariableFailed(string envVarName, string exceptionMessage) => WriteEvent(5, envVarName, exceptionMessage); + + [Event(6, Message = "Failed to map unknown EventSource log level in AzureEventSourceLogForwarder {0}", Level = EventLevel.Warning)] + public void MapLogLevelFailed(string level) => WriteEvent(6, level); + + [Event(7, Message = "Found existing Microsoft.Extensions.Azure.AzureEventSourceLogForwarder registration.", Level = EventLevel.Informational)] + public void LogForwarderIsAlreadyRegistered() => WriteEvent(7); } } diff --git a/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/src/Internals/AzureSdkCompat/AzureEventSourceLogForwarder.cs b/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/src/Internals/AzureSdkCompat/AzureEventSourceLogForwarder.cs new file mode 100644 index 0000000000000..df510d1a35a12 --- /dev/null +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/src/Internals/AzureSdkCompat/AzureEventSourceLogForwarder.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using System.Diagnostics.Tracing; +using Azure.Core.Diagnostics; +using Azure.Core.Shared; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Azure.Monitor.OpenTelemetry.AspNetCore.Internals.AzureSdkCompat +{ + internal sealed class AzureEventSourceLogForwarder : IHostedService, IDisposable + { + internal static readonly AzureEventSourceLogForwarder Noop = new AzureEventSourceLogForwarder(null); + private readonly ILoggerFactory _loggerFactory; + + private readonly ConcurrentDictionary _loggers = new ConcurrentDictionary(); + + private readonly Func _formatMessage = FormatMessage; + + private AzureEventSourceListener _listener; + + public AzureEventSourceLogForwarder(ILoggerFactory loggerFactory) + { + _loggerFactory = loggerFactory; + } + + private void LogEvent(EventWrittenEventArgs eventData) + { + var logger = _loggers.GetOrAdd(eventData.EventSource.Name, name => _loggerFactory!.CreateLogger(ToLoggerName(name))); + logger.Log(MapLevel(eventData.Level), new EventId(eventData.EventId, eventData.EventName), new EventSourceEvent(eventData), null, _formatMessage); + } + + private static string ToLoggerName(string name) + { + return name.Replace('-', '.'); + } + + private static LogLevel MapLevel(EventLevel level) + { + switch (level) + { + case EventLevel.Critical: + return LogLevel.Critical; + case EventLevel.Error: + return LogLevel.Error; + case EventLevel.Informational: + return LogLevel.Information; + case EventLevel.Verbose: + return LogLevel.Debug; + case EventLevel.Warning: + return LogLevel.Warning; + case EventLevel.LogAlways: + return LogLevel.Information; + default: + AzureMonitorAspNetCoreEventSource.Log.MapLogLevelFailed(level); + return LogLevel.None; + } + } + + private static string FormatMessage(EventSourceEvent eventSourceEvent, Exception _) => eventSourceEvent.Format(); + + public Task StartAsync(CancellationToken cancellationToken) + { + if (_loggerFactory != null) + { + _listener ??= new AzureEventSourceListener((e, s) => LogEvent(e), EventLevel.Verbose); + } + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _listener?.Dispose(); + return Task.CompletedTask; + } + + public void Dispose() + { + _listener?.Dispose(); + } + } +} diff --git a/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/src/OpenTelemetryBuilderExtensions.cs b/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/src/OpenTelemetryBuilderExtensions.cs index 707847e5d4d1f..689dbfc97b38b 100644 --- a/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/src/OpenTelemetryBuilderExtensions.cs +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/src/OpenTelemetryBuilderExtensions.cs @@ -4,6 +4,7 @@ #nullable enable using System.Reflection; +using Azure.Monitor.OpenTelemetry.AspNetCore.Internals.AzureSdkCompat; using Azure.Monitor.OpenTelemetry.AspNetCore.Internals.Profiling; using Azure.Monitor.OpenTelemetry.Exporter; using Azure.Monitor.OpenTelemetry.LiveMetrics; @@ -184,6 +185,21 @@ public static OpenTelemetryBuilder UseAzureMonitor(this OpenTelemetryBuilder bui azureMonitorOptions.Get(Options.DefaultName).SetValueToLiveMetricsExporterOptions(exporterOptions); }); + // Register Azure SDK log forwarder in the case it was not registered by the user application. + builder.Services.AddHostedService(sp => + { + var logForwarderType = Type.GetType("Microsoft.Extensions.Azure.AzureEventSourceLogForwarder, Microsoft.Extensions.Azure", false); + + if (logForwarderType != null && sp.GetService(logForwarderType) != null) + { + AzureMonitorAspNetCoreEventSource.Log.LogForwarderIsAlreadyRegistered(); + return AzureEventSourceLogForwarder.Noop; + } + + var loggerFactory = sp.GetRequiredService(); + return new AzureEventSourceLogForwarder(loggerFactory); + }); + return builder; } diff --git a/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/tests/Azure.Monitor.OpenTelemetry.AspNetCore.Tests/Azure.Monitor.OpenTelemetry.AspNetCore.Tests.csproj b/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/tests/Azure.Monitor.OpenTelemetry.AspNetCore.Tests/Azure.Monitor.OpenTelemetry.AspNetCore.Tests.csproj index 61e2477d4349f..e5872fb6415a2 100644 --- a/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/tests/Azure.Monitor.OpenTelemetry.AspNetCore.Tests/Azure.Monitor.OpenTelemetry.AspNetCore.Tests.csproj +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/tests/Azure.Monitor.OpenTelemetry.AspNetCore.Tests/Azure.Monitor.OpenTelemetry.AspNetCore.Tests.csproj @@ -5,6 +5,7 @@ + @@ -17,6 +18,7 @@ + diff --git a/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/tests/Azure.Monitor.OpenTelemetry.AspNetCore.Tests/AzureSdkLoggingTests.cs b/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/tests/Azure.Monitor.OpenTelemetry.AspNetCore.Tests/AzureSdkLoggingTests.cs new file mode 100644 index 0000000000000..32f5b1bca52d1 --- /dev/null +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/tests/Azure.Monitor.OpenTelemetry.AspNetCore.Tests/AzureSdkLoggingTests.cs @@ -0,0 +1,197 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#if NET6_0_OR_GREATER +using System; +using System.Collections.Generic; +using System.Diagnostics.Tracing; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core.Diagnostics; +using Azure.Core.TestFramework; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Azure.Monitor.OpenTelemetry.AspNetCore.Tests +{ + public class AzureSdkLoggingTests + { + [Theory] + [InlineData(LogLevel.Information, "TestInfoEvent: hello")] + [InlineData(LogLevel.Warning, "TestWarningEvent: hello")] + [InlineData(LogLevel.Debug, null)] + public async Task DistroLogForwarderIsAdded(LogLevel eventLevel, string expectedMessage) + { + var builder = WebApplication.CreateBuilder(); + var transport = new MockTransport(new MockResponse(200).SetContent("ok")); + SetUpOTelAndLogging(builder, transport, LogLevel.Information); + + using var app = builder.Build(); + await app.StartAsync(); + + using TestEventSource source = new TestEventSource(); + Assert.True(source.IsEnabled()); + source.LogMessage("hello", eventLevel); + WaitForRequest(transport); + if (expectedMessage != null) + { + Assert.Single(transport.Requests); + await AssertContentContains(transport.Requests.Single(), expectedMessage, eventLevel); + } + else + { + await AssertContentDoesNotContain(transport.Requests, "hello"); + } + } + + [Theory] + [InlineData(LogLevel.Information, "TestInfoEvent: hello")] + [InlineData(LogLevel.Warning, "TestWarningEvent: hello")] + [InlineData(LogLevel.Debug, null)] + public async Task PublicLogForwarderIsAdded(LogLevel eventLevel, string expectedMessage) + { + var builder = WebApplication.CreateBuilder(); + var transport = new MockTransport(new MockResponse(200).SetContent("ok")); + SetUpOTelAndLogging(builder, transport, LogLevel.Information); + + builder.Services.TryAddSingleton(); + using var app = builder.Build(); + + Microsoft.Extensions.Azure.AzureEventSourceLogForwarder publicLogForwarder = + app.Services.GetRequiredService(); + + Assert.NotNull(publicLogForwarder); + publicLogForwarder.Start(); + + await app.StartAsync(); + + using TestEventSource source = new TestEventSource(); + Assert.True(source.IsEnabled()); + source.LogMessage("hello", eventLevel); + + WaitForRequest(transport); + if (expectedMessage != null) + { + Assert.Single(transport.Requests); + await AssertContentContains(transport.Requests.Single(), expectedMessage, eventLevel); + } + else + { + await AssertContentDoesNotContain(transport.Requests, "hello"); + } + } + + private void WaitForRequest(MockTransport transport) + { + SpinWait.SpinUntil( + condition: () => + { + Thread.Sleep(10); + return transport.Requests.Count > 0; + }, + timeout: TimeSpan.FromSeconds(10)); + } + + private static async Task AssertContentContains(MockRequest request, string expectedMessage, LogLevel expectedLevel) + { + using var contentStream = new MemoryStream(); + await request.Content.WriteToAsync(contentStream, default); + contentStream.Position = 0; + var content = BinaryData.FromStream(contentStream).ToString(); + var jsonMessage = $"\"message\":\"{expectedMessage}\""; + var jsonLevel = $"\"severityLevel\":\"{expectedLevel}\""; + Assert.Contains(jsonMessage, content); + Assert.Contains(jsonLevel, content); + + // also check that message appears just once + Assert.Equal(content.IndexOf(jsonMessage), content.LastIndexOf(jsonMessage)); + } + + private static async Task AssertContentDoesNotContain(List requests, string expectedMessage) + { + foreach (var request in requests) + { + using var contentStream = new MemoryStream(); + await request.Content.WriteToAsync(contentStream, default); + contentStream.Position = 0; + var content = BinaryData.FromStream(contentStream).ToString(); + Console.WriteLine(content); + + Assert.DoesNotContain(expectedMessage, content); + } + } + + private static void SetUpOTelAndLogging(WebApplicationBuilder builder, MockTransport transport, LogLevel enableLevel) + { + builder.Logging.ClearProviders(); + builder.Logging.AddFilter((name, level) => + { + if (name != null && name.StartsWith("Azure")) + { + return level >= enableLevel; + } + return false; + }); + + builder.Services.AddOpenTelemetry().UseAzureMonitor(config => + { + config.Transport = transport; + config.ConnectionString = $"InstrumentationKey={Guid.NewGuid()}"; + config.EnableLiveMetrics = false; + config.Diagnostics.IsDistributedTracingEnabled = false; + config.Diagnostics.IsLoggingEnabled = false; + }); + } + + internal class TestEventSource : AzureEventSource + { + private const string EventSourceName = "Azure-Test"; + public TestEventSource() : base(EventSourceName) + { + } + + [Event(1, Level = EventLevel.Informational, Message = "TestInfoEvent: {0}")] + public void LogTestInfoEvent(string message) + { + WriteEvent(1, message); + } + + [Event(2, Level = EventLevel.Verbose, Message = "TestVerboseEvent: {0}")] + public void LogTestVerboseEvent(string message) + { + WriteEvent(2, message); + } + + [Event(3, Level = EventLevel.Warning, Message = "TestWarningEvent: {0}")] + public void LogTestWarningEvent(string message) + { + WriteEvent(3, message); + } + + public void LogMessage(string message, LogLevel level) + { + switch (level) + { + case LogLevel.Warning: + LogTestWarningEvent(message); + break; + case LogLevel.Information: + LogTestInfoEvent(message); + break; + case LogLevel.Debug: + LogTestVerboseEvent(message); + break; + default: + Assert.Fail("Log level is not supported"); + break; + } + } + } + } +} +#endif