diff --git a/src/Elastic.Apm/Extensions/TransactionExtensions.cs b/src/Elastic.Apm/Extensions/TransactionExtensions.cs index ef2127eba..36aee9c0e 100644 --- a/src/Elastic.Apm/Extensions/TransactionExtensions.cs +++ b/src/Elastic.Apm/Extensions/TransactionExtensions.cs @@ -24,7 +24,9 @@ internal static class TransactionExtensions internal static void CollectRequestBody(this ITransaction transaction, bool isForError, IHttpRequestAdapter httpRequest, IApmLogger logger) { if (!transaction.IsSampled) + { return; + } if (httpRequest == null || !httpRequest.HasValue) return; @@ -34,9 +36,19 @@ internal static void CollectRequestBody(this ITransaction transaction, bool isFo // Is request body already captured? // We check transaction.IsContextCreated to avoid creating empty Context (that accessing transaction.Context directly would have done). var hasContext = transaction is Transaction { IsContextCreated: true } || transaction.Context != null; - if (hasContext - && transaction.Context.Request.Body != null - && !ReferenceEquals(transaction.Context.Request.Body, Consts.Redacted)) + var hasCapturedBody = hasContext && transaction.Context.Request?.Body != null; + + // If CaptureBody is set to "transactions" and it is an error then we shouldn't capture it. + // If the body has already been captured then it has to be redacted. + if (isForError && transaction.Configuration.CaptureBody.Equals(ConfigConsts.SupportedValues.CaptureBodyTransactions)) + { + if (hasCapturedBody) + transaction.Context.Request.Body = Consts.Redacted; + + return; + } + + if (hasCapturedBody && !ReferenceEquals(transaction.Context.Request.Body, Consts.Redacted)) return; if (transaction.Configuration.CaptureBody.Equals(ConfigConsts.SupportedValues.CaptureBodyOff)) diff --git a/src/integrations/Elastic.Apm.AspNetCore/AspNetCoreHttpRequest.cs b/src/integrations/Elastic.Apm.AspNetCore/AspNetCoreHttpRequest.cs index 2a4b19d26..6c9b62ad1 100644 --- a/src/integrations/Elastic.Apm.AspNetCore/AspNetCoreHttpRequest.cs +++ b/src/integrations/Elastic.Apm.AspNetCore/AspNetCoreHttpRequest.cs @@ -14,12 +14,38 @@ internal class AspNetCoreHttpRequest : IHttpRequestAdapter { private readonly HttpRequest _request; - internal AspNetCoreHttpRequest(HttpRequest request) => _request = request; + internal AspNetCoreHttpRequest(HttpRequest request, IConfiguration configuration) + { + _request = request; + + if (configuration?.CaptureBody == ConfigConsts.SupportedValues.CaptureBodyErrors) + { + _request?.EnableBuffering(); + } + } public string ExtractBody(IConfiguration configuration, IApmLogger logger, out bool longerThanMaxLength) { longerThanMaxLength = false; - return _request?.ExtractRequestBody(configuration, out longerThanMaxLength); + + var shouldBeBuffered = configuration?.CaptureBody == ConfigConsts.SupportedValues.CaptureBodyErrors; + + // Enable buffering if CaptureBody is set to "errors" + if (shouldBeBuffered) + { + // Reset stream position to the beginning in case the body was already read + _request.Body.Position = 0; + } + + var bodyContent = _request?.ExtractRequestBody(configuration, out longerThanMaxLength); + + // Reset stream position if buffering was enabled + if (shouldBeBuffered) + { + _request.Body.Position = 0; + } + + return bodyContent; } public bool HasValue => _request != null; diff --git a/src/integrations/Elastic.Apm.AspNetCore/DiagnosticListener/AspNetCoreDiagnosticListener.cs b/src/integrations/Elastic.Apm.AspNetCore/DiagnosticListener/AspNetCoreDiagnosticListener.cs index e382a82d4..597121a15 100644 --- a/src/integrations/Elastic.Apm.AspNetCore/DiagnosticListener/AspNetCoreDiagnosticListener.cs +++ b/src/integrations/Elastic.Apm.AspNetCore/DiagnosticListener/AspNetCoreDiagnosticListener.cs @@ -96,7 +96,7 @@ private bool HandleException(PropertyFetcher propertyFetcher, PropertyFetcher ex return false; if (iTransaction is Transaction transaction) { - transaction.CollectRequestBody(true, new AspNetCoreHttpRequest(exception.Request), Logger); + transaction.CollectRequestBody(true, new AspNetCoreHttpRequest(exception.Request, transaction.Configuration), Logger); transaction.CaptureException(httpContextException, isHandled: isHandled); } diff --git a/src/integrations/Elastic.Apm.AspNetCore/WebRequestTransactionCreator.cs b/src/integrations/Elastic.Apm.AspNetCore/WebRequestTransactionCreator.cs index 36adeeba1..bcd7f18ba 100644 --- a/src/integrations/Elastic.Apm.AspNetCore/WebRequestTransactionCreator.cs +++ b/src/integrations/Elastic.Apm.AspNetCore/WebRequestTransactionCreator.cs @@ -122,7 +122,7 @@ private static void FillSampledTransactionContextRequest(HttpContext context, Tr Headers = GetHeaders(context.Request.Headers, transaction.Configuration) }; - transaction.CollectRequestBody(false, new AspNetCoreHttpRequest(context.Request), logger); + transaction.CollectRequestBody(false, new AspNetCoreHttpRequest(context.Request, transaction.Configuration), logger); } catch (Exception ex) { diff --git a/test/integrations/Elastic.Apm.AspNetCore.Tests/BodyCapturingTests.cs b/test/integrations/Elastic.Apm.AspNetCore.Tests/BodyCapturingTests.cs index 34bee5535..cf126d4f2 100644 --- a/test/integrations/Elastic.Apm.AspNetCore.Tests/BodyCapturingTests.cs +++ b/test/integrations/Elastic.Apm.AspNetCore.Tests/BodyCapturingTests.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; using System.Net; @@ -285,6 +284,206 @@ public async Task ApmMiddleware_ShouldSkipCapturing_WhenInvalidContentType() result.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); } + [Fact] + public async Task When_CaptureBodyConfigurationToAllAndSuccessfulCall_Should_CaptureBody() + { + var sutEnv = StartSutEnv(CreateConfiguration()); + + // build test data, which we send to the sample app + var data = new { Name = "John" }; + + var body = JsonConvert.SerializeObject(data, + new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() }); + + // send data to the sample app + var result = await sutEnv.HttpClient.PostAsync("api/Home/Send", new StringContent(body, Encoding.UTF8, "application/json")); + + // wait for the payload sender to receive the transaction + sutEnv.MockPayloadSender.WaitForTransactions(TimeSpan.FromSeconds(10)); + + // make sure the sample app received the data + result.StatusCode.Should().Be((HttpStatusCode)200); + + // and make sure the data is captured by the agent + sutEnv.MockPayloadSender.FirstTransaction.Should().NotBeNull(); + sutEnv.MockPayloadSender.FirstTransaction.Context.Request.Body.Should().Be(body); + } + + [Fact] + public async Task When_CaptureBodyConfigurationToTransactionsAndSuccessfulCall_Should_CaptureBody() + { + var sutEnv = StartSutEnv(CreateConfiguration(ConfigConsts.SupportedValues.CaptureBodyTransactions)); + + // build test data, which we send to the sample app + var data = new { Name = "John" }; + + var body = JsonConvert.SerializeObject(data, + new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() }); + + // send data to the sample app + var result = await sutEnv.HttpClient.PostAsync("api/Home/Send", new StringContent(body, Encoding.UTF8, "application/json")); + + // wait for the payload sender to receive the transaction + sutEnv.MockPayloadSender.WaitForTransactions(TimeSpan.FromSeconds(3)); + + // make sure the sample app received the data + result.StatusCode.Should().Be((HttpStatusCode)200); + + // and make sure the data is captured by the agent + sutEnv.MockPayloadSender.FirstTransaction.Should().NotBeNull(); + sutEnv.MockPayloadSender.FirstTransaction.Context.Request.Body.Should().Be(body); + } + + [Fact] + public async Task When_CaptureBodyConfigurationToOffAndSuccessfulCall_ShouldNot_CaptureBody() + { + var sutEnv = StartSutEnv(CreateConfiguration(ConfigConsts.SupportedValues.CaptureBodyOff)); + + // build test data, which we send to the sample app + var data = new { Name = "John" }; + + var body = JsonConvert.SerializeObject(data, + new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() }); + + // send data to the sample app + var result = await sutEnv.HttpClient.PostAsync("api/Home/Send", new StringContent(body, Encoding.UTF8, "application/json")); + + // wait for the payload sender to receive the transaction + sutEnv.MockPayloadSender.WaitForTransactions(TimeSpan.FromSeconds(3)); + + // make sure the sample app received the data + result.StatusCode.Should().Be((HttpStatusCode)200); + + // and make sure the data is captured by the agent + sutEnv.MockPayloadSender.FirstTransaction.Should().NotBeNull(); + sutEnv.MockPayloadSender.FirstTransaction.Context.Request.Body.Should().Be("[REDACTED]"); + } + + [Fact] + public async Task When_CaptureBodyConfigurationToErrorsAndSuccessfulCall_ShouldNot_CaptureBody() + { + var sutEnv = StartSutEnv(CreateConfiguration(ConfigConsts.SupportedValues.CaptureBodyErrors)); + + // build test data, which we send to the sample app + var data = new { Name = "John" }; + + var body = JsonConvert.SerializeObject(data, + new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() }); + + // send data to the sample app + var result = await sutEnv.HttpClient.PostAsync("api/Home/Send", new StringContent(body, Encoding.UTF8, "application/json")); + + // wait for the payload sender to receive the transaction + sutEnv.MockPayloadSender.WaitForTransactions(TimeSpan.FromSeconds(3)); + + // make sure the sample app received the data + result.StatusCode.Should().Be((HttpStatusCode)200); + + // and make sure the data is captured by the agent + sutEnv.MockPayloadSender.FirstTransaction.Should().NotBeNull(); + sutEnv.MockPayloadSender.FirstTransaction.Context.Request.Body.Should().BeNull(); + } + + [Fact] + public async Task When_CaptureBodyConfigurationToAllAndFailingCall_Should_CaptureBody() + { + var sutEnv = StartSutEnv(CreateConfiguration()); + + // build test data, which we send to the sample app + var data = new { Name = "John" }; + + var body = JsonConvert.SerializeObject(data, + new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() }); + + // send data to the sample app + var result = await sutEnv.HttpClient.PostAsync("api/Home/SendError", new StringContent(body, Encoding.UTF8, "application/json")); + + // wait for the payload sender to receive the transaction + sutEnv.MockPayloadSender.WaitForTransactions(TimeSpan.FromSeconds(3)); + + // make sure the sample app received the data + result.StatusCode.Should().Be((HttpStatusCode)500); + + // and make sure the data is captured by the agent + sutEnv.MockPayloadSender.FirstTransaction.Should().NotBeNull(); + sutEnv.MockPayloadSender.FirstTransaction.Context.Request.Body.Should().Be(body); + } + + [Fact] + public async Task When_CaptureBodyConfigurationToErrorsAndFailingCall_Should_CaptureBody() + { + var sutEnv = StartSutEnv(CreateConfiguration(ConfigConsts.SupportedValues.CaptureBodyErrors)); + + // build test data, which we send to the sample app + var data = new { Name = "John" }; + + var body = JsonConvert.SerializeObject(data, + new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() }); + + // send data to the sample app + var result = await sutEnv.HttpClient.PostAsync("api/Home/SendError", new StringContent(body, Encoding.UTF8, "application/json")); + + // wait for the payload sender to receive the transaction + sutEnv.MockPayloadSender.WaitForTransactions(TimeSpan.FromSeconds(3)); + + // make sure the sample app received the data + result.StatusCode.Should().Be((HttpStatusCode)500); + + // and make sure the data is captured by the agent + sutEnv.MockPayloadSender.FirstTransaction.Should().NotBeNull(); + sutEnv.MockPayloadSender.FirstTransaction.Context.Request.Body.Should().Be(body); + } + + [Fact] + public async Task When_CaptureBodyConfigurationToTransactionsAndFailingCall_ShouldNot_CaptureBody() + { + var sutEnv = StartSutEnv(CreateConfiguration(ConfigConsts.SupportedValues.CaptureBodyTransactions)); + + // build test data, which we send to the sample app + var data = new { Name = "John" }; + + var body = JsonConvert.SerializeObject(data, + new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() }); + + // send data to the sample app + var result = await sutEnv.HttpClient.PostAsync("api/Home/SendError", new StringContent(body, Encoding.UTF8, "application/json")); + + // wait for the payload sender to receive the transaction + sutEnv.MockPayloadSender.WaitForTransactions(TimeSpan.FromSeconds(3)); + + // make sure the sample app received the data + result.StatusCode.Should().Be((HttpStatusCode)500); + + // and make sure the data is captured by the agent + sutEnv.MockPayloadSender.FirstTransaction.Should().NotBeNull(); + sutEnv.MockPayloadSender.FirstTransaction.Context.Request.Body.Should().Be("[REDACTED]"); + } + + [Fact] + public async Task When_CaptureBodyConfigurationToOffAndFailingCall_ShouldNot_CaptureBody() + { + var sutEnv = StartSutEnv(CreateConfiguration(ConfigConsts.SupportedValues.CaptureBodyOff)); + + // build test data, which we send to the sample app + var data = new { Name = "John" }; + + var body = JsonConvert.SerializeObject(data, + new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() }); + + // send data to the sample app + var result = await sutEnv.HttpClient.PostAsync("api/Home/SendError", new StringContent(body, Encoding.UTF8, "application/json")); + + // wait for the payload sender to receive the transaction + sutEnv.MockPayloadSender.WaitForTransactions(TimeSpan.FromSeconds(3)); + + // make sure the sample app received the data + result.StatusCode.Should().Be((HttpStatusCode)500); + + // and make sure the data is captured by the agent + sutEnv.MockPayloadSender.FirstTransaction.Should().NotBeNull(); + sutEnv.MockPayloadSender.FirstTransaction.Context.Request.Body.Should().Be("[REDACTED]"); + } + private static IEnumerable BuildOptionsTestVariants() { var captureBodyContentTypesVariants = new[] diff --git a/test/integrations/applications/SampleAspNetCoreApp/Controllers/HomeController.cs b/test/integrations/applications/SampleAspNetCoreApp/Controllers/HomeController.cs index 8b19dbf04..75374a478 100644 --- a/test/integrations/applications/SampleAspNetCoreApp/Controllers/HomeController.cs +++ b/test/integrations/applications/SampleAspNetCoreApp/Controllers/HomeController.cs @@ -284,6 +284,13 @@ private async Task T3() [HttpPost("api/Home/Send")] public IActionResult Send([FromBody] BaseReportFilter filter) => filter == null ? StatusCode(500) : Ok(); + /// + /// A test case to make sure that CaptureBoby is working as expected in case of failure. + /// + /// HTTP500 + [HttpPost("api/Home/SendError")] + public IActionResult SendError([FromBody] BaseReportFilter filter) => throw new Exception("This is a post method test exception!"); + /// /// A test case to make sure that setting manually is not overwritten by auto instrumentation. ///