diff --git a/samples/Samples.AspNetCore/Controllers/TestController.cs b/samples/Samples.AspNetCore/Controllers/TestController.cs index 7aa633a6..f3bf1b9a 100644 --- a/samples/Samples.AspNetCore/Controllers/TestController.cs +++ b/samples/Samples.AspNetCore/Controllers/TestController.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using StackExchange.Exceptional; using System; using System.Threading.Tasks; @@ -7,6 +8,17 @@ namespace Samples.AspNetCore.Controllers { public class TestController : Controller { + private readonly ILogger _logger; + + public TestController(ILogger logger) => _logger = logger; + + public ActionResult Logger() + { + var ex = new Exception("Test Exception for ILogger goodness."); + _logger.LogError(ex, ex.Message); + return Content("Check the log!"); + } + public async Task Throw() { await ExceptionalUtils.Test.GetRedisException().LogAsync(ControllerContext.HttpContext).ConfigureAwait(false); diff --git a/samples/Samples.AspNetCore/Startup.cs b/samples/Samples.AspNetCore/Startup.cs index 85552931..46e37910 100644 --- a/samples/Samples.AspNetCore/Startup.cs +++ b/samples/Samples.AspNetCore/Startup.cs @@ -1,7 +1,9 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Samples.AspNetCore { @@ -20,6 +22,11 @@ public Startup(IConfiguration configuration, IHostingEnvironment env) public void ConfigureServices(IServiceCollection services) { services.AddMvc(); + // (Optional): If you want ILogger calls that log an exception to have request details, + // then it needs access to the HttpContext statically, this registers that ability. + // If you're using Identity or ApplicationInsights, this is already registered. + // If using .NET Core 2.1+, you can call the new helper instead: services.AddHttpContextAccessor(); + services.TryAddSingleton(); // Make IOptions available for injection everywhere services.AddExceptional(Configuration.GetSection("Exceptional"), settings => { diff --git a/samples/Samples.AspNetCore/Views/Shared/_Layout.cshtml b/samples/Samples.AspNetCore/Views/Shared/_Layout.cshtml index 9b8772bd..3406f4c0 100644 --- a/samples/Samples.AspNetCore/Views/Shared/_Layout.cshtml +++ b/samples/Samples.AspNetCore/Views/Shared/_Layout.cshtml @@ -33,6 +33,7 @@
  • Home
  • Exceptions
  • Throw
  • +
  • ILogger
  • diff --git a/src/StackExchange.Exceptional.AspNetCore/ExceptionalBuilderExtensions.cs b/src/StackExchange.Exceptional.AspNetCore/ExceptionalBuilderExtensions.cs index 07563978..1710d862 100644 --- a/src/StackExchange.Exceptional.AspNetCore/ExceptionalBuilderExtensions.cs +++ b/src/StackExchange.Exceptional.AspNetCore/ExceptionalBuilderExtensions.cs @@ -1,4 +1,6 @@ -using StackExchange.Exceptional; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using StackExchange.Exceptional; using System; namespace Microsoft.AspNetCore.Builder @@ -16,7 +18,11 @@ public static class ExceptionalBuilderExtensions public static IApplicationBuilder UseExceptional(this IApplicationBuilder builder) { _ = builder ?? throw new ArgumentNullException(nameof(builder)); + + var loggerFactory = builder.ApplicationServices.GetRequiredService(); + loggerFactory.AddProvider(builder.ApplicationServices.GetRequiredService()); + return builder.UseMiddleware(); } } -} \ No newline at end of file +} diff --git a/src/StackExchange.Exceptional.AspNetCore/ExceptionalLogger.cs b/src/StackExchange.Exceptional.AspNetCore/ExceptionalLogger.cs new file mode 100644 index 00000000..47e214e5 --- /dev/null +++ b/src/StackExchange.Exceptional.AspNetCore/ExceptionalLogger.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace StackExchange.Exceptional +{ + internal class ExceptionalLogger : ILogger + { + private readonly string _category; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IOptions _settings; + + public ExceptionalLogger(string category, IOptions settings, IHttpContextAccessor httpContextAccessor = null) + { + _category = category; + _settings = settings; + _httpContextAccessor = httpContextAccessor; + } + + public IDisposable BeginScope(TState state) => null; + + public bool IsEnabled(LogLevel logLevel) => _settings.Value.ILoggerLevel <= logLevel; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + // Ignore non-exceptions and Exceptional events themselves + if (exception == null || (ExceptionalLoggingEvents.Min <=eventId.Id && eventId.Id <= ExceptionalLoggingEvents.Max)) + { + return; + } + + var customData = new Dictionary + { + ["AspNetCore.LogLevel"] = logLevel.ToString(), + ["AspNetCore.EventId.Id"] = eventId.Id.ToString(), + ["AspNetCore.EventId.Name"] = eventId.Name, + ["AspNetCore.Message"] = formatter(state, exception), + }; + + if (_httpContextAccessor?.HttpContext is HttpContext context) + { + exception.Log(context, _category, customData: customData); + } + else + { + exception.LogNoContext(_category, customData: customData); + } + } + } +} diff --git a/src/StackExchange.Exceptional.AspNetCore/ExceptionalLoggerProvider.cs b/src/StackExchange.Exceptional.AspNetCore/ExceptionalLoggerProvider.cs new file mode 100644 index 00000000..b742d4ee --- /dev/null +++ b/src/StackExchange.Exceptional.AspNetCore/ExceptionalLoggerProvider.cs @@ -0,0 +1,25 @@ + +using System; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace StackExchange.Exceptional +{ + internal class ExceptionalLoggerProvider : ILoggerProvider + { + private readonly IOptions _settings; + private readonly IHttpContextAccessor _httpContextAccessor; + + public ExceptionalLoggerProvider(IOptions settings, IHttpContextAccessor httpContextAccessor = null) + { + _settings = settings; + _httpContextAccessor = httpContextAccessor; + } + + ILogger ILoggerProvider.CreateLogger(string categoryName) + => new ExceptionalLogger(categoryName, _settings, _httpContextAccessor); + + void IDisposable.Dispose() { } + } +} diff --git a/src/StackExchange.Exceptional.AspNetCore/ExceptionalLoggingEvents.cs b/src/StackExchange.Exceptional.AspNetCore/ExceptionalLoggingEvents.cs new file mode 100644 index 00000000..701b49f1 --- /dev/null +++ b/src/StackExchange.Exceptional.AspNetCore/ExceptionalLoggingEvents.cs @@ -0,0 +1,21 @@ +namespace StackExchange.Exceptional +{ + /// + /// Events IDs for known exceptional events while logging. + /// + public static class ExceptionalLoggingEvents + { + internal const int Min = RequestException; + internal const int Max = ExceptionalPageException; + + /// + /// A request threw an exception, caught by Exceptional Middleware. + /// + public const int RequestException = 77000; + + /// + /// A request was thrown while trying to render the Exceptional error page. + /// + public const int ExceptionalPageException = 77001; + } +} diff --git a/src/StackExchange.Exceptional.AspNetCore/ExceptionalMiddleware.cs b/src/StackExchange.Exceptional.AspNetCore/ExceptionalMiddleware.cs index 049d6c22..2db1c0ed 100644 --- a/src/StackExchange.Exceptional.AspNetCore/ExceptionalMiddleware.cs +++ b/src/StackExchange.Exceptional.AspNetCore/ExceptionalMiddleware.cs @@ -63,7 +63,7 @@ public async Task Invoke(HttpContext context) } catch (Exception ex) { - _logger.LogError(0, ex, "An unhandled exception has occurred, logging to Exceptional"); + _logger.LogError(ExceptionalLoggingEvents.RequestException, ex, "An unhandled exception has occurred, logging to Exceptional"); var error = await ex.LogAsync(context).ConfigureAwait(false); // If options say to do so, show the exception page to the user @@ -101,7 +101,7 @@ public async Task Invoke(HttpContext context) } catch (Exception pex) { - _logger.LogError(0, pex, "An exception was thrown attempting to display the Exceptional page."); + _logger.LogError(ExceptionalLoggingEvents.ExceptionalPageException, pex, "An exception was thrown attempting to display the Exceptional page."); } } throw; diff --git a/src/StackExchange.Exceptional.AspNetCore/ExceptionalServiceExtensions.cs b/src/StackExchange.Exceptional.AspNetCore/ExceptionalServiceExtensions.cs index 56fd269d..6ed973b7 100644 --- a/src/StackExchange.Exceptional.AspNetCore/ExceptionalServiceExtensions.cs +++ b/src/StackExchange.Exceptional.AspNetCore/ExceptionalServiceExtensions.cs @@ -36,6 +36,9 @@ public static IServiceCollection AddExceptional(this IServiceCollection services // When done configuring, set the background settings object for non-context logging. services.Configure(Exceptional.Configure); + // Setup for ILogger + services.AddSingleton(); + return services; } } diff --git a/src/StackExchange.Exceptional.AspNetCore/ExceptionalSettings.cs b/src/StackExchange.Exceptional.AspNetCore/ExceptionalSettings.cs index a245bcfc..c0cb4654 100644 --- a/src/StackExchange.Exceptional.AspNetCore/ExceptionalSettings.cs +++ b/src/StackExchange.Exceptional.AspNetCore/ExceptionalSettings.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; using StackExchange.Exceptional.Internal; using System; @@ -19,5 +20,11 @@ public class ExceptionalSettings : ExceptionalSettingsBase /// but may need to be replaced in special multi-proxy situations. /// public Func GetIPAddress { get; set; } = context => context.Connection.RemoteIpAddress.ToString(); + + /// + /// The minimum log level for . + /// Defaults to . + /// + public LogLevel ILoggerLevel { get; set; } = LogLevel.Error; } } diff --git a/src/StackExchange.Exceptional.Shared/Error.cs b/src/StackExchange.Exceptional.Shared/Error.cs index ba42e699..e41212fb 100644 --- a/src/StackExchange.Exceptional.Shared/Error.cs +++ b/src/StackExchange.Exceptional.Shared/Error.cs @@ -119,6 +119,10 @@ private void AddData(Exception exception) foreach (string k in exception.Data.Keys) { + if (k == Constants.LoggedDataKey) + { + continue; + } if (regex?.IsMatch(k) == true) { InitCustomData(); @@ -201,8 +205,18 @@ public bool LogToStore(ErrorStore store = null) if (abort) return false; // if we've been told to abort, then abort dammit! Trace.WriteLine(Exception); // always echo the error to trace for local debugging + + if (Exception.Data?.Contains(Constants.LoggedDataKey) == true) + { + // already logged - match the GUIDs up + GUID = (Guid)Exception.Data[Constants.LoggedDataKey]; + return true; + } + store.Log(this); + if (Exception.Data != null) Exception.Data[Constants.LoggedDataKey] = GUID; // mark as logged + Settings.AfterLog(this, store); return true; @@ -220,8 +234,18 @@ public async Task LogToStoreAsync(ErrorStore store = null) if (abort) return true; // if we've been told to abort, then abort dammit! Trace.WriteLine(Exception); // always echo the error to trace for local debugging + + if (Exception.Data?.Contains(Constants.LoggedDataKey) == true) + { + // already logged - match the GUIDs up + GUID = (Guid)Exception.Data[Constants.LoggedDataKey]; + return true; + } + await store.LogAsync(this).ConfigureAwait(false); + if (Exception.Data != null) Exception.Data[Constants.LoggedDataKey] = GUID; // mark as logged + Settings.AfterLog(this, store); return true; diff --git a/src/StackExchange.Exceptional.Shared/Internal/Constants.cs b/src/StackExchange.Exceptional.Shared/Internal/Constants.cs index e4f7417e..a3e68641 100644 --- a/src/StackExchange.Exceptional.Shared/Internal/Constants.cs +++ b/src/StackExchange.Exceptional.Shared/Internal/Constants.cs @@ -19,5 +19,10 @@ public static class Constants /// Key for prefixing fields in .Data for logging to CustomData /// public const string CustomDataKeyPrefix = "ExceptionalCustom-"; + + /// + /// The key in Exception.Data that indicates Exceptional has already handled this exception and should ignore future attempts to log it. + /// + public const string LoggedDataKey = "Exceptional.Logged"; } } diff --git a/tests/StackExchange.Exceptional.Tests.AspNetCore/Logging.cs b/tests/StackExchange.Exceptional.Tests.AspNetCore/Logging.cs index 0a6ca7c1..9c3215f9 100644 --- a/tests/StackExchange.Exceptional.Tests.AspNetCore/Logging.cs +++ b/tests/StackExchange.Exceptional.Tests.AspNetCore/Logging.cs @@ -1,8 +1,16 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net.Http; using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using StackExchange.Exceptional.Stores; using Xunit; using Xunit.Abstractions; @@ -138,5 +146,60 @@ public async Task LogNoContext() Assert.Null(error.ServerVariables); } } + + [Fact] + public async Task ILoggerLogging() + { + Error error = null; + using (var server = new TestServer(new WebHostBuilder() + .ConfigureServices(services => + { + services.TryAddSingleton(); + services.AddExceptional(s => + { + s.DefaultStore = new MemoryErrorStore(); + CurrentSettings = s; + }); + }) + .Configure(app => + { + var logger = app.ApplicationServices.GetRequiredService>(); + app.UseExceptional(); + app.Run(async context => + { + var ex = new Exception("Log!"); + logger.LogError(ex, ex.Message); + var errors = await CurrentSettings.DefaultStore.GetAllAsync(); + error = errors.FirstOrDefault(); + await context.Response.WriteAsync("Hey."); + }); + }))) + { + using (var response = await server.CreateClient().GetAsync("?QueryKey=QueryValue").ConfigureAwait(false)) + { + Assert.Equal("Hey.", await response.Content.ReadAsStringAsync().ConfigureAwait(false)); + } + + Assert.Equal("Log!", error.Message); + Assert.Equal("System.Exception", error.Type); + Assert.Equal(Environment.MachineName, error.MachineName); + Assert.Equal("localhost", error.Host); + Assert.Equal("http://localhost/?QueryKey=QueryValue", error.FullUrl); + Assert.Equal("/", error.UrlPath); + + Assert.NotEmpty(error.RequestHeaders); + Assert.Equal("localhost", error.RequestHeaders["Host"]); + + Assert.Single(error.QueryString); + Assert.Equal("QueryValue", error.QueryString["QueryKey"]); + + Assert.NotEmpty(error.ServerVariables); + Assert.Equal("localhost", error.ServerVariables["Host"]); + Assert.Equal("/", error.ServerVariables["Path"]); + Assert.Equal("GET", error.ServerVariables["Request Method"]); + Assert.Equal("http", error.ServerVariables["Scheme"]); + Assert.Equal("http://localhost/?QueryKey=QueryValue", error.ServerVariables["Url"]); + } + } } } diff --git a/tests/StackExchange.Exceptional.Tests/StackExchange.Exceptional.Tests.csproj b/tests/StackExchange.Exceptional.Tests/StackExchange.Exceptional.Tests.csproj index f32e836a..e4c689a7 100644 --- a/tests/StackExchange.Exceptional.Tests/StackExchange.Exceptional.Tests.csproj +++ b/tests/StackExchange.Exceptional.Tests/StackExchange.Exceptional.Tests.csproj @@ -9,7 +9,7 @@ - +