diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index c6a99cf2..dd6677c7 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -2,18 +2,19 @@ name: Build Linux on: [push, pull_request] env: DOTNET_CLI_TELEMETRY_OPTOUT: 1 + DOTNET_NOLOGO: 1 jobs: build: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Setup .NET Core 8 - uses: actions/setup-dotnet@v4 + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 with: - dotnet-version: 8.x + global-json-file: global.json - name: Build Reason run: "echo ref: ${{github.ref}} event: ${{github.event_name}}" - name: Build Version diff --git a/.github/workflows/build-osx.yml b/.github/workflows/build-osx.yml index d7f08e32..83852de2 100644 --- a/.github/workflows/build-osx.yml +++ b/.github/workflows/build-osx.yml @@ -2,18 +2,19 @@ name: Build OSX on: [push, pull_request] env: DOTNET_CLI_TELEMETRY_OPTOUT: 1 + DOTNET_NOLOGO: 1 jobs: build: runs-on: macOS-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Setup .NET Core 8 - uses: actions/setup-dotnet@v4 + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 with: - dotnet-version: 8.x + global-json-file: global.json - name: Build Reason run: "echo ref: ${{github.ref}} event: ${{github.event_name}}" - name: Build Version diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index dc6af569..3efed70e 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -2,18 +2,19 @@ name: Build Windows on: [push, pull_request] env: DOTNET_CLI_TELEMETRY_OPTOUT: 1 + DOTNET_NOLOGO: 1 jobs: build: runs-on: windows-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Setup .NET Core 8 - uses: actions/setup-dotnet@v4 + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 with: - dotnet-version: 8.x + global-json-file: global.json - name: Build Reason run: "echo ref: ${{github.ref}} event: ${{github.event_name}}" - name: Build Version diff --git a/build/common.props b/build/common.props index b2b0ca80..76e6fddb 100644 --- a/build/common.props +++ b/build/common.props @@ -6,8 +6,9 @@ https://github.com/exceptionless/Exceptionless.Net/releases true v + true - Copyright (c) 2025 Exceptionless. All rights reserved. + Copyright © $([System.DateTime]::Now.ToString(yyyy)) Exceptionless. All rights reserved. Exceptionless $(NoWarn);CS1591;NU1701 true diff --git a/global.json b/global.json index 989a69ca..1e7fdfa9 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.100", + "version": "10.0.100", "rollForward": "latestMinor" } -} \ No newline at end of file +} diff --git a/samples/Exceptionless.SampleAspNetCore/Controllers/ValuesController.cs b/samples/Exceptionless.SampleAspNetCore/Controllers/ValuesController.cs index b6bce7b5..964450dc 100644 --- a/samples/Exceptionless.SampleAspNetCore/Controllers/ValuesController.cs +++ b/samples/Exceptionless.SampleAspNetCore/Controllers/ValuesController.cs @@ -26,14 +26,16 @@ public Dictionary Get() { try { throw new Exception($"Handled Exception: {Guid.NewGuid()}"); - } catch (Exception handledException) { + } + catch (Exception handledException) { // Use the ToExceptionless extension method to submit this handled exception to Exceptionless using the client instance from DI. handledException.ToExceptionless(_exceptionlessClient).Submit(); } try { throw new Exception($"Handled Exception (Default Client): {Guid.NewGuid()}"); - } catch (Exception handledException) { + } + catch (Exception handledException) { // Use the ToExceptionless extension method to submit this handled exception to Exceptionless using the default client instance (ExceptionlessClient.Default). // This works and is convenient, but its generally not recommended to use static singleton instances because it makes testing and // other things harder. @@ -44,4 +46,4 @@ public Dictionary Get() { throw new Exception($"Unhandled Exception: {Guid.NewGuid()}"); } } -} \ No newline at end of file +} diff --git a/src/Platforms/Exceptionless.AspNetCore/ExceptionlessAspNetCorePlugin.cs b/src/Platforms/Exceptionless.AspNetCore/ExceptionlessAspNetCorePlugin.cs index f6fd4b52..b7def64b 100644 --- a/src/Platforms/Exceptionless.AspNetCore/ExceptionlessAspNetCorePlugin.cs +++ b/src/Platforms/Exceptionless.AspNetCore/ExceptionlessAspNetCorePlugin.cs @@ -32,7 +32,7 @@ public void Run(EventPluginContext context) { return; try { - ri = httpContext.GetRequestInfo(context.Client.Configuration); + ri = httpContext.GetRequestInfo(context.Client.Configuration, context.ContextData.IsUnhandledError); } catch (Exception ex) { context.Log.Error(typeof(ExceptionlessAspNetCorePlugin), ex, "Error adding request info."); } diff --git a/src/Platforms/Exceptionless.AspNetCore/ExceptionlessExtensions.cs b/src/Platforms/Exceptionless.AspNetCore/ExceptionlessExtensions.cs index 240767d3..cbe6f0c9 100644 --- a/src/Platforms/Exceptionless.AspNetCore/ExceptionlessExtensions.cs +++ b/src/Platforms/Exceptionless.AspNetCore/ExceptionlessExtensions.cs @@ -68,8 +68,9 @@ public static IApplicationBuilder UseExceptionless(this IApplicationBuilder app, /// /// The http context to gather information from. /// The config. - public static RequestInfo GetRequestInfo(this HttpContext context, ExceptionlessConfiguration config) { - return RequestInfoCollector.Collect(context, config); + /// Whether this is an unhandled error. POST data is only collected for unhandled errors to avoid consuming the request stream. + public static RequestInfo GetRequestInfo(this HttpContext context, ExceptionlessConfiguration config, bool isUnhandledError = false) { + return RequestInfoCollector.Collect(context, config, isUnhandledError); } /// diff --git a/src/Platforms/Exceptionless.AspNetCore/RequestInfoCollector.cs b/src/Platforms/Exceptionless.AspNetCore/RequestInfoCollector.cs index 31bebdd2..146ec89a 100644 --- a/src/Platforms/Exceptionless.AspNetCore/RequestInfoCollector.cs +++ b/src/Platforms/Exceptionless.AspNetCore/RequestInfoCollector.cs @@ -16,7 +16,7 @@ public static class RequestInfoCollector { private const int MAX_BODY_SIZE = 50 * 1024; private const int MAX_DATA_ITEM_LENGTH = 1000; - public static RequestInfo Collect(HttpContext context, ExceptionlessConfiguration config) { + public static RequestInfo Collect(HttpContext context, ExceptionlessConfiguration config, bool isUnhandledError = false) { if (context == null) return null; @@ -50,7 +50,9 @@ public static RequestInfo Collect(HttpContext context, ExceptionlessConfiguratio if (config.IncludeQueryString) info.QueryString = context.Request.Query.ToDictionary(exclusionList); - if (config.IncludePostData && !String.Equals(context.Request.Method, "GET", StringComparison.OrdinalIgnoreCase)) + // Only collect POST data for unhandled errors to avoid consuming the request stream + // and breaking model binding for handled errors where the app continues processing. + if (config.IncludePostData && isUnhandledError && !String.Equals(context.Request.Method, "GET", StringComparison.OrdinalIgnoreCase)) info.PostData = GetPostData(context, config, exclusionList); return info; @@ -59,13 +61,8 @@ public static RequestInfo Collect(HttpContext context, ExceptionlessConfiguratio private static object GetPostData(HttpContext context, ExceptionlessConfiguration config, string[] exclusionList) { var log = config.Resolver.GetLog(); - if (context.Request.HasFormContentType && context.Request.Form.Count > 0) { - log.Debug("Reading POST data from Request.Form"); - return context.Request.Form.ToDictionary(exclusionList); - } - - var contentLength = context.Request.ContentLength.GetValueOrDefault(); - if(contentLength == 0) { + long contentLength = context.Request.ContentLength.GetValueOrDefault(); + if (contentLength == 0) { string message = "Content-length was zero, empty post."; log.Debug(message); return message; @@ -98,6 +95,14 @@ private static object GetPostData(HttpContext context, ExceptionlessConfiguratio return message; } + // Form check must come after seekability and position checks above: accessing + // Request.Form triggers reading the request body stream. + if (context.Request.HasFormContentType && context.Request.Form.Count > 0) { + log.Debug("Reading POST data from Request.Form"); + context.Request.Body.Position = originalPosition; + return context.Request.Form.ToDictionary(exclusionList); + } + // pass default values, except for leaveOpen: true. This prevents us from disposing the underlying stream using (var inputStream = new StreamReader(context.Request.Body, Encoding.UTF8, true, 1024, true)) { var sb = new StringBuilder(); diff --git a/src/Platforms/Exceptionless.Web/ExceptionlessWebExtensions.cs b/src/Platforms/Exceptionless.Web/ExceptionlessWebExtensions.cs index 6fa97f19..50fecd2f 100644 --- a/src/Platforms/Exceptionless.Web/ExceptionlessWebExtensions.cs +++ b/src/Platforms/Exceptionless.Web/ExceptionlessWebExtensions.cs @@ -11,11 +11,12 @@ public static class ExceptionlessWebExtensions { /// /// The http context to gather information from. /// The config. - public static RequestInfo GetRequestInfo(this HttpContext context, ExceptionlessConfiguration config) { + /// Whether this is an unhandled error. POST data is only collected for unhandled errors to avoid consuming the request stream. + public static RequestInfo GetRequestInfo(this HttpContext context, ExceptionlessConfiguration config, bool isUnhandledError = false) { if (context == null) return null; - return GetRequestInfo(context.ToWrapped(), config); + return GetRequestInfo(context.ToWrapped(), config, isUnhandledError); } /// @@ -23,11 +24,12 @@ public static RequestInfo GetRequestInfo(this HttpContext context, Exceptionless /// /// The http context to gather information from. /// The config. - public static RequestInfo GetRequestInfo(this HttpContextBase context, ExceptionlessConfiguration config) { + /// Whether this is an unhandled error. POST data is only collected for unhandled errors to avoid consuming the request stream. + public static RequestInfo GetRequestInfo(this HttpContextBase context, ExceptionlessConfiguration config, bool isUnhandledError = false) { if (context == null && HttpContext.Current != null) context = HttpContext.Current.ToWrapped(); - return RequestInfoCollector.Collect(context, config); + return RequestInfoCollector.Collect(context, config, isUnhandledError); } /// diff --git a/src/Platforms/Exceptionless.Web/ExceptionlessWebPlugin.cs b/src/Platforms/Exceptionless.Web/ExceptionlessWebPlugin.cs index 856732a5..703b0b47 100644 --- a/src/Platforms/Exceptionless.Web/ExceptionlessWebPlugin.cs +++ b/src/Platforms/Exceptionless.Web/ExceptionlessWebPlugin.cs @@ -35,7 +35,7 @@ public void Run(EventPluginContext context) { return; try { - ri = httpContext.GetRequestInfo(context.Client.Configuration); + ri = httpContext.GetRequestInfo(context.Client.Configuration, context.ContextData.IsUnhandledError); } catch (Exception ex) { context.Log.Error(typeof(ExceptionlessWebPlugin), ex, "Error adding request info."); } diff --git a/src/Platforms/Exceptionless.Web/RequestInfoCollector.cs b/src/Platforms/Exceptionless.Web/RequestInfoCollector.cs index ebebdb64..dd864dd8 100644 --- a/src/Platforms/Exceptionless.Web/RequestInfoCollector.cs +++ b/src/Platforms/Exceptionless.Web/RequestInfoCollector.cs @@ -15,7 +15,7 @@ internal static class RequestInfoCollector { private const int MAX_BODY_SIZE = 50 * 1024; private const int MAX_DATA_ITEM_LENGTH = 1000; - public static RequestInfo Collect(HttpContextBase context, ExceptionlessConfiguration config) { + public static RequestInfo Collect(HttpContextBase context, ExceptionlessConfiguration config, bool isUnhandledError = false) { if (context == null) return null; @@ -63,7 +63,9 @@ public static RequestInfo Collect(HttpContextBase context, ExceptionlessConfigur } } - if (config.IncludePostData && !String.Equals(context.Request.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase)) + // Only collect POST data for unhandled errors to avoid consuming the request stream + // and breaking model binding for handled errors where the app continues processing. + if (config.IncludePostData && isUnhandledError && !String.Equals(context.Request.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase)) info.PostData = GetPostData(context, config, exclusionList); return info; @@ -72,13 +74,7 @@ public static RequestInfo Collect(HttpContextBase context, ExceptionlessConfigur private static object GetPostData(HttpContextBase context, ExceptionlessConfiguration config, string[] exclusionList) { var log = config.Resolver.GetLog(); - if (context.Request.Form.Count > 0) { - log.Debug("Reading POST data from Request.Form"); - - return context.Request.Form.ToDictionary(exclusionList); - } - - var contentLength = context.Request.ContentLength; + int contentLength = context.Request.ContentLength; if (contentLength == 0) { string message = "Content-length was zero, empty post."; log.Debug(message); @@ -112,7 +108,15 @@ private static object GetPostData(HttpContextBase context, ExceptionlessConfigur return message; } - var maxDataToRead = contentLength == 0 ? MAX_BODY_SIZE : contentLength; + // Form check must come after seekability and position checks above: accessing + // Request.Form triggers reading the request body stream. + if (context.Request.Form.Count > 0) { + log.Debug("Reading POST data from Request.Form"); + context.Request.InputStream.Position = originalPosition; + return context.Request.Form.ToDictionary(exclusionList); + } + + int maxDataToRead = contentLength == 0 ? MAX_BODY_SIZE : contentLength; // pass default values, except for leaveOpen: true. This prevents us from disposing the underlying stream using (var inputStream = new StreamReader(context.Request.InputStream, Encoding.UTF8, true, 1024, true)) { @@ -233,4 +237,4 @@ private static string GetUserIpAddress(HttpContextBase context) { return clientIp; } } -} \ No newline at end of file +} diff --git a/src/Platforms/Exceptionless.WebApi/ExceptionlessWebApiExtensions.cs b/src/Platforms/Exceptionless.WebApi/ExceptionlessWebApiExtensions.cs index 338d35eb..b2fa787e 100644 --- a/src/Platforms/Exceptionless.WebApi/ExceptionlessWebApiExtensions.cs +++ b/src/Platforms/Exceptionless.WebApi/ExceptionlessWebApiExtensions.cs @@ -65,8 +65,9 @@ private static void ReplaceHttpErrorHandler(HttpConfiguration config, Exceptionl /// /// The http action context to gather information from. /// The config. - public static RequestInfo GetRequestInfo(this HttpActionContext context, ExceptionlessConfiguration config) { - return RequestInfoCollector.Collect(context, config); + /// Whether this is an unhandled error. POST data collection is not implemented for WebApi. + public static RequestInfo GetRequestInfo(this HttpActionContext context, ExceptionlessConfiguration config, bool isUnhandledError = false) { + return RequestInfoCollector.Collect(context, config, isUnhandledError); } /// diff --git a/src/Platforms/Exceptionless.WebApi/ExceptionlessWebApiPlugin.cs b/src/Platforms/Exceptionless.WebApi/ExceptionlessWebApiPlugin.cs index 3328cdd8..0fdba372 100644 --- a/src/Platforms/Exceptionless.WebApi/ExceptionlessWebApiPlugin.cs +++ b/src/Platforms/Exceptionless.WebApi/ExceptionlessWebApiPlugin.cs @@ -29,7 +29,7 @@ public void Run(EventPluginContext context) { return; try { - ri = actionContext.GetRequestInfo(context.Client.Configuration); + ri = actionContext.GetRequestInfo(context.Client.Configuration, context.ContextData.IsUnhandledError); } catch (Exception ex) { context.Log.Error(typeof(ExceptionlessWebApiPlugin), ex, "Error adding request info."); } diff --git a/src/Platforms/Exceptionless.WebApi/RequestInfoCollector.cs b/src/Platforms/Exceptionless.WebApi/RequestInfoCollector.cs index 7f5298ee..b3f5b521 100644 --- a/src/Platforms/Exceptionless.WebApi/RequestInfoCollector.cs +++ b/src/Platforms/Exceptionless.WebApi/RequestInfoCollector.cs @@ -14,7 +14,7 @@ namespace Exceptionless.ExtendedData { internal static class RequestInfoCollector { private const int MAX_DATA_ITEM_LENGTH = 1000; - public static RequestInfo Collect(HttpActionContext context, ExceptionlessConfiguration config) { + public static RequestInfo Collect(HttpActionContext context, ExceptionlessConfiguration config, bool isUnhandledError = false) { if (context == null) return null; @@ -48,8 +48,11 @@ public static RequestInfo Collect(HttpActionContext context, ExceptionlessConfig if (config.IncludeQueryString) info.QueryString = context.Request.RequestUri.ParseQueryString().ToDictionary(exclusionList); - // TODO: support getting post data asyncly. - //if (config.IncludePostData && context.Request.Method != HttpMethod.Get) + // POST data collection is not implemented for WebApi due to async complexities and + // the difficulty of reading the request body without interfering with model binding. + // Other platforms (AspNetCore, Web) now only collect POST data for unhandled errors. + // TODO: support getting post data asynchronously. + //if (config.IncludePostData && isUnhandledError && context.Request.Method != HttpMethod.Get) // info.PostData = GetPostData(context, config, exclusionList); return info; diff --git a/test/Exceptionless.Tests/Exceptionless.Tests.csproj b/test/Exceptionless.Tests/Exceptionless.Tests.csproj index 0bfb4e74..f4f37a35 100644 --- a/test/Exceptionless.Tests/Exceptionless.Tests.csproj +++ b/test/Exceptionless.Tests/Exceptionless.Tests.csproj @@ -2,10 +2,10 @@ -net8.0 +net10.0 -net8.0;net462 +net10.0;net462 @@ -19,7 +19,7 @@ - + $(DefineConstants);NETSTANDARD;NETSTANDARD2_0 @@ -29,10 +29,12 @@ + + @@ -54,4 +56,4 @@ PreserveNewest - \ No newline at end of file + diff --git a/test/Exceptionless.Tests/Platforms/AspNetCoreRequestInfoTests.cs b/test/Exceptionless.Tests/Platforms/AspNetCoreRequestInfoTests.cs new file mode 100644 index 00000000..599c87db --- /dev/null +++ b/test/Exceptionless.Tests/Platforms/AspNetCoreRequestInfoTests.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using System.IO; +using System.Text; +using Exceptionless; +using Exceptionless.Dependency; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using Xunit; + +namespace Exceptionless.Tests.Platforms { + public class AspNetCoreRequestInfoTests { + [Fact] + public void GetRequestInfo_DoesNotReadPostData_ForHandledErrors() { + // Arrange + var context = CreateHttpContext("hello=world"); + var config = new ExceptionlessConfiguration(DependencyResolver.CreateDefault()); + + // Act + var requestInfo = context.GetRequestInfo(config); + + // Assert + Assert.NotNull(requestInfo); + Assert.Null(requestInfo.PostData); + Assert.Equal(0L, context.Request.Body.Position); + } + + [Fact] + public void GetRequestInfo_ReadsAndRestoresPostData_ForUnhandledErrors() { + // Arrange + const string body = "{\"hello\":\"world\"}"; + var context = CreateHttpContext(body); + var config = new ExceptionlessConfiguration(DependencyResolver.CreateDefault()); + + context.Request.Body.Position = 5; + + // Act + var requestInfo = context.GetRequestInfo(config, isUnhandledError: true); + + // Assert + Assert.NotNull(requestInfo); + Assert.Equal(body, Assert.IsType(requestInfo.PostData)); + Assert.Equal(5L, context.Request.Body.Position); + } + + [Fact] + public void GetRequestInfo_ReadsFormData_ForUnhandledErrors() { + // Arrange + var context = CreateFormHttpContext(); + var config = new ExceptionlessConfiguration(DependencyResolver.CreateDefault()); + + // Act + var requestInfo = context.GetRequestInfo(config, isUnhandledError: true); + + // Assert + Assert.NotNull(requestInfo); + var postData = Assert.IsType>(requestInfo.PostData); + Assert.Equal("world", postData["name"]); + } + + private static DefaultHttpContext CreateHttpContext(string body) { + var bodyBytes = Encoding.UTF8.GetBytes(body); + var context = new DefaultHttpContext(); + context.Request.Method = HttpMethods.Post; + context.Request.ContentType = "application/json"; + context.Request.Body = new MemoryStream(bodyBytes); + context.Request.ContentLength = bodyBytes.Length; + return context; + } + + private static DefaultHttpContext CreateFormHttpContext() { + const string formBody = "name=world"; + var bodyBytes = Encoding.UTF8.GetBytes(formBody); + var context = new DefaultHttpContext(); + context.Request.Method = HttpMethods.Post; + context.Request.ContentType = "application/x-www-form-urlencoded"; + context.Request.ContentLength = bodyBytes.Length; + context.Request.Body = new MemoryStream(bodyBytes); + context.Request.Form = new FormCollection(new Dictionary { + ["name"] = "world" + }); + return context; + } + } +}