From 30038c49d6c07d7e2aa8399e4936b26ceb49bcb6 Mon Sep 17 00:00:00 2001 From: Russ Cam Date: Thu, 29 Oct 2020 18:50:30 +1000 Subject: [PATCH] Add HttpContextCurrentExecutionSegmentsContainer (#992) * Add HttpContextCurrentExecutionSegmentsContainer This commit adds an implementation of ICurrentExecutionSegmentsContainer that gets/sets the current transaction and span in HttpContext.Items, in addition to AsyncLocal, making them available within the ExecutionContext of IIS. AsyncLocal are not propagated across the ASP.NET ExecutionContext running in IIS, resulting in the current transaction set in Application_BeginRequest returning as null from Agent.Tracer.CurrentTransaction in later subsequent IIS events, when such events execute asynchronously. ASP.NET does however propagate the current HttpContext through HttpContext.Current, allowing values to be stored in HttpContext.Items hashtable and shared across IIS events. The HttpContextCurrentExecutionSegmentsContainer uses both AsyncLocal and HttpContext.Items to get/set the current transaction and span, allowing them to be available in both IIS events and async contexts. AsyncLocal is still needed for places where HttpContext.Current is null, for example, DiagnosticSourceListeners running on a separate thread. It seems it may be possible for the current transaction and current span instances stored in AsyncLocal and HttpContext.Items to be different in such scenarios where one is available but not the other on beginning the transaction/span, and where availability is reversed on end. Unsure how likely this is in practice however, and using both AsyncLocal and HttpContext.Items is the common approach in resolving this issue in < .NET 4.7.1. For .NET 4.7.1, a new OnExecuteRequestStep method has been added to HttpApplication, to allow the execution context to be restored/saved: https://devblogs.microsoft.com/dotnet/net-framework-4-7-1-asp-net-and-configuration-features/#asp-net-execution-step-feature Closes #934 Closes #972 * tidy after rebase * Add concurrent requests test This commit adds an integration test for HttpContextCurrentExecutionSegmentsContainer that makes multiple concurrent requests and asserts that the captured transactions and span ids align with expectations. --- .../AspNetFullFrameworkSampleApp.csproj | 40 +++-- .../Bootstrap/Alert.cs | 7 + .../Controllers/ControllerBase.cs | 6 + .../Controllers/DatabaseController.cs | 123 ++++++++++++++ .../Controllers/HomeController.cs | 21 ++- .../Global.asax.cs | 5 + .../Models/CreateSampleDataViewModel.cs | 15 ++ .../Mvc/JsonBadRequestResult.cs | 13 ++ .../Mvc/JsonNetValueProviderFactory.cs | 130 ++++++++++++++ .../Mvc/StreamResult.cs | 45 +++++ .../Views/Database/Create.cshtml | 18 ++ .../Views/Database/Index.cshtml | 26 +++ .../ElasticApmModule.cs | 48 +++--- ...ontextCurrentExecutionSegmentsContainer.cs | 48 ++++++ .../ICurrentExecutionSegmentsContainer.cs | 7 + ...astic.Apm.AspNetFullFramework.Tests.csproj | 2 +- ...tCurrentExecutionSegmentsContainerTests.cs | 158 ++++++++++++++++++ 17 files changed, 661 insertions(+), 51 deletions(-) create mode 100644 sample/AspNetFullFrameworkSampleApp/Controllers/DatabaseController.cs create mode 100644 sample/AspNetFullFrameworkSampleApp/Models/CreateSampleDataViewModel.cs create mode 100644 sample/AspNetFullFrameworkSampleApp/Mvc/JsonBadRequestResult.cs create mode 100644 sample/AspNetFullFrameworkSampleApp/Mvc/JsonNetValueProviderFactory.cs create mode 100644 sample/AspNetFullFrameworkSampleApp/Mvc/StreamResult.cs create mode 100644 sample/AspNetFullFrameworkSampleApp/Views/Database/Create.cshtml create mode 100644 sample/AspNetFullFrameworkSampleApp/Views/Database/Index.cshtml create mode 100644 src/Elastic.Apm.AspNetFullFramework/HttpContextCurrentExecutionSegmentsContainer.cs create mode 100644 test/Elastic.Apm.AspNetFullFramework.Tests/HttpContextCurrentExecutionSegmentsContainerTests.cs diff --git a/sample/AspNetFullFrameworkSampleApp/AspNetFullFrameworkSampleApp.csproj b/sample/AspNetFullFrameworkSampleApp/AspNetFullFrameworkSampleApp.csproj index bed3f6aeb..1ec7de1c7 100644 --- a/sample/AspNetFullFrameworkSampleApp/AspNetFullFrameworkSampleApp.csproj +++ b/sample/AspNetFullFrameworkSampleApp/AspNetFullFrameworkSampleApp.csproj @@ -125,6 +125,13 @@ ASPXCodeBehind Webforms.aspx + + + + + + + @@ -202,6 +209,9 @@ + + + @@ -246,18 +256,30 @@ 6.3.0 + + 2.2.3 + + + 2.2.3 + 5.2.4 1.0.7 + + 5.2.4 + 1.1.3 2.0.1 + + 4.1.1 + 11.0.2 @@ -273,24 +295,6 @@ 4.5.3 - - 2.2.3 - - - 2.2.3 - - - 4.1.1 - - - - - - - - - 5.2.4 - 10.0 diff --git a/sample/AspNetFullFrameworkSampleApp/Bootstrap/Alert.cs b/sample/AspNetFullFrameworkSampleApp/Bootstrap/Alert.cs index 56202147a..4b789f582 100644 --- a/sample/AspNetFullFrameworkSampleApp/Bootstrap/Alert.cs +++ b/sample/AspNetFullFrameworkSampleApp/Bootstrap/Alert.cs @@ -18,6 +18,13 @@ public class SuccessAlert : Alert public SuccessAlert(string title, string message) : base(title, message) => Status = AlertStatus.Success; } + public class DangerAlert : Alert + { + public DangerAlert(string message) : base(message) => Status = AlertStatus.Danger; + + public DangerAlert(string title, string message) : base(title, message) => Status = AlertStatus.Danger; + } + public class Alert : IHtmlString { public Alert(string message) => Message = message; diff --git a/sample/AspNetFullFrameworkSampleApp/Controllers/ControllerBase.cs b/sample/AspNetFullFrameworkSampleApp/Controllers/ControllerBase.cs index e75b289fd..3477eca20 100644 --- a/sample/AspNetFullFrameworkSampleApp/Controllers/ControllerBase.cs +++ b/sample/AspNetFullFrameworkSampleApp/Controllers/ControllerBase.cs @@ -3,14 +3,20 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.IO; using System.Web.Mvc; using AspNetFullFrameworkSampleApp.Bootstrap; using AspNetFullFrameworkSampleApp.Extensions; +using AspNetFullFrameworkSampleApp.Mvc; namespace AspNetFullFrameworkSampleApp.Controllers { public abstract class ControllerBase : Controller { protected void AddAlert(Alert alert) => TempData.Put("alert", alert); + + protected ActionResult JsonBadRequest(object content) => new JsonBadRequestResult { Data = content, }; + + protected ActionResult Stream(Stream stream, string contentType, int statusCode) => new StreamResult(stream, contentType, statusCode); } } diff --git a/sample/AspNetFullFrameworkSampleApp/Controllers/DatabaseController.cs b/sample/AspNetFullFrameworkSampleApp/Controllers/DatabaseController.cs new file mode 100644 index 000000000..a9d34b787 --- /dev/null +++ b/sample/AspNetFullFrameworkSampleApp/Controllers/DatabaseController.cs @@ -0,0 +1,123 @@ +// Licensed to Elasticsearch B.V under +// one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Collections.Generic; +using System.Data.Entity; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using System.Web.Mvc; +using AspNetFullFrameworkSampleApp.Bootstrap; +using AspNetFullFrameworkSampleApp.Data; +using AspNetFullFrameworkSampleApp.Models; +using Newtonsoft.Json; + +namespace AspNetFullFrameworkSampleApp.Controllers +{ + /// + /// Demonstrates database functionality + /// + public class DatabaseController : ControllerBase + { + /// + /// List the sample data in the database + /// + /// + public ActionResult Index() + { + using var context = new SampleDataDbContext(); + var samples = context.Set().ToList(); + return View(samples); + } + + /// + /// Allows sample data to be inserted into the database + /// + public ActionResult Create() => View(new CreateSampleDataViewModel()); + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Create(CreateSampleDataViewModel model) + { + if (!ModelState.IsValid) + return View(model); + + int changes; + using (var context = new SampleDataDbContext()) + { + var sampleData = new SampleData { Name = model.Name }; + context.Set().Add(sampleData); + changes = await context.SaveChangesAsync(); + } + + AddAlert(new SuccessAlert("Sample added", $"{changes} sample was saved to the database")); + return RedirectToAction("Index"); + } + + // TODO: make this a web api controller action + /// + /// Generates the given count of sample data, and calls the bulk action to insert into the database + /// + /// + /// This intentionally makes an async HTTP call to bulk insert. + /// + /// The count of sample data to generate + [HttpPost] + public async Task Generate(int count) + { + if (!ModelState.IsValid) + return JsonBadRequest(new { success = false, message = "Invalid samples" }); + + if (count <= 0) + return JsonBadRequest(new { success = false, message = "count must be greater than 0" }); + + int existingCount; + using (var context = new SampleDataDbContext()) + existingCount = await context.Set().CountAsync(); + + var samples = Enumerable.Range(existingCount, count) + .Select(i => new CreateSampleDataViewModel { Name = $"Generated sample {i}" }); + + var client = new HttpClient(); + var bulkUrl = Url.Action("Bulk", "Database", null, Request.Url.Scheme); + var json = JsonConvert.SerializeObject(samples); + var contentType = "application/json"; + var content = new StringContent(json, Encoding.UTF8, contentType); + + var response = await client.PostAsync(bulkUrl, content).ConfigureAwait(false); + var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + + return Stream(responseStream, contentType, (int)response.StatusCode); + } + + // TODO: make this a web api controller action + /// + /// Bulk inserts sample data into the database + /// + /// The sample data to insert + [HttpPost] + public async Task Bulk(IEnumerable model) + { + if (!ModelState.IsValid) + return JsonBadRequest(new { success = false, message = "Invalid samples" }); + + var sampleData = model.Select(m => new SampleData { Name = m.Name }); + int changes; + + using (var context = new SampleDataDbContext()) + using (var transaction = context.Database.BeginTransaction()) + { + context.Configuration.AutoDetectChangesEnabled = false; + context.Configuration.ValidateOnSaveEnabled = false; + context.Set().AddRange(sampleData); + changes = await context.SaveChangesAsync(); + transaction.Commit(); + } + + return Json(new { success = true, changes }); + } + } +} diff --git a/sample/AspNetFullFrameworkSampleApp/Controllers/HomeController.cs b/sample/AspNetFullFrameworkSampleApp/Controllers/HomeController.cs index cb90a3506..b303331a6 100644 --- a/sample/AspNetFullFrameworkSampleApp/Controllers/HomeController.cs +++ b/sample/AspNetFullFrameworkSampleApp/Controllers/HomeController.cs @@ -92,13 +92,20 @@ public async Task ChildHttpSpanWithResponseForbidden() public Task Contact() { var httpClient = new HttpClient(); + var callToThisAppUrl = new Uri(Request.Url.ToString().Replace(ContactPageRelativePath, AboutPageRelativePath)); + var callToExternalServiceUrl = ChildHttpCallToExternalServiceUrl; return SafeCaptureSpan($"{ContactSpanPrefix}{SpanNameSuffix}", $"{ContactSpanPrefix}{SpanTypeSuffix}", async () => { - var callToThisAppUrl = - new Uri(HttpContext.ApplicationInstance.Request.Url.ToString().Replace(ContactPageRelativePath, AboutPageRelativePath)); + async Task GetContentFromUrl(Uri url) + { + Console.WriteLine($"Getting `{url}'..."); + var response = await httpClient.GetAsync(url).ConfigureAwait(false); + Console.WriteLine($"Response status code from `{url}' - {response.StatusCode}"); + return response; + } + var responseFromLocalHost = await GetContentFromUrl(callToThisAppUrl); - var callToExternalServiceUrl = ChildHttpCallToExternalServiceUrl; var responseFromElasticCo = await GetContentFromUrl(callToExternalServiceUrl); ViewBag.Message = @@ -108,14 +115,6 @@ public Task Contact() return View(); }, $"{ContactSpanPrefix}{SpanSubtypeSuffix}", $"{ContactSpanPrefix}{SpanActionSuffix}", GetCaptureControllerActionAsSpan()); - - async Task GetContentFromUrl(Uri urlToGet) - { - Console.WriteLine($"Getting `{urlToGet}'..."); - var response = await httpClient.GetAsync(urlToGet); - Console.WriteLine($"Response status code from `{urlToGet}' - {response.StatusCode}"); - return response; - } } public ActionResult Sample(int id) => Content(id.ToString()); diff --git a/sample/AspNetFullFrameworkSampleApp/Global.asax.cs b/sample/AspNetFullFrameworkSampleApp/Global.asax.cs index 007d8fb2c..4d2476afa 100644 --- a/sample/AspNetFullFrameworkSampleApp/Global.asax.cs +++ b/sample/AspNetFullFrameworkSampleApp/Global.asax.cs @@ -5,12 +5,14 @@ using System; using System.Collections.Specialized; using System.Diagnostics; +using System.Linq; using System.Web; using System.Web.Http; using System.Web.Http.Batch; using System.Web.Mvc; using System.Web.Optimization; using System.Web.Routing; +using AspNetFullFrameworkSampleApp.Mvc; using Elastic.Apm; using NLog; @@ -40,6 +42,9 @@ protected void Application_Start() FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters); RouteConfig.RegisterRoutes(RouteTable.Routes); BundleConfig.RegisterBundles(BundleTable.Bundles); + + ValueProviderFactories.Factories.Remove(ValueProviderFactories.Factories.OfType().FirstOrDefault()); + ValueProviderFactories.Factories.Add(new JsonNetValueProviderFactory()); } protected void Application_BeginRequest(object sender, EventArgs e) diff --git a/sample/AspNetFullFrameworkSampleApp/Models/CreateSampleDataViewModel.cs b/sample/AspNetFullFrameworkSampleApp/Models/CreateSampleDataViewModel.cs new file mode 100644 index 000000000..c7a7a719d --- /dev/null +++ b/sample/AspNetFullFrameworkSampleApp/Models/CreateSampleDataViewModel.cs @@ -0,0 +1,15 @@ +// Licensed to Elasticsearch B.V under +// one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.ComponentModel.DataAnnotations; + +namespace AspNetFullFrameworkSampleApp.Models +{ + public class CreateSampleDataViewModel + { + [Required] + public string Name { get; set; } + } +} diff --git a/sample/AspNetFullFrameworkSampleApp/Mvc/JsonBadRequestResult.cs b/sample/AspNetFullFrameworkSampleApp/Mvc/JsonBadRequestResult.cs new file mode 100644 index 000000000..84930a3d8 --- /dev/null +++ b/sample/AspNetFullFrameworkSampleApp/Mvc/JsonBadRequestResult.cs @@ -0,0 +1,13 @@ +using System.Web.Mvc; + +namespace AspNetFullFrameworkSampleApp.Mvc +{ + public class JsonBadRequestResult : JsonResult + { + public override void ExecuteResult(ControllerContext context) + { + context.RequestContext.HttpContext.Response.StatusCode = 400; + base.ExecuteResult(context); + } + } +} diff --git a/sample/AspNetFullFrameworkSampleApp/Mvc/JsonNetValueProviderFactory.cs b/sample/AspNetFullFrameworkSampleApp/Mvc/JsonNetValueProviderFactory.cs new file mode 100644 index 000000000..6a0db2dff --- /dev/null +++ b/sample/AspNetFullFrameworkSampleApp/Mvc/JsonNetValueProviderFactory.cs @@ -0,0 +1,130 @@ +// Licensed to Elasticsearch B.V under +// one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +// https://gist.github.com/rorymurphy/db0b02e8267960a0881a +// This is a slightly modified ValueProviderFactory based almost entirely on Microsoft's JsonValueProviderFactory. +// That file is licensed under the Apache 2.0 license, but I am leaving the copyright statement below nonetheless. +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.txt in the project root for license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Dynamic; +using System.Globalization; +using System.IO; +using System.Web.Mvc; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace AspNetFullFrameworkSampleApp.Mvc +{ + /// + /// A value provider factory for application/json requests that uses + /// Json.NET to deserialize the request stream and provide the + /// value to subsequent steps. + /// + public sealed class JsonNetValueProviderFactory : ValueProviderFactory + { + private static readonly JsonSerializer Serializer = new JsonSerializer + { + Converters = { new ExpandoObjectConverter() } + }; + + private static void AddToBackingStore(EntryLimitedDictionary backingStore, string prefix, object value) + { + switch (value) + { + case IDictionary d: + { + foreach (var entry in d) + AddToBackingStore(backingStore, MakePropertyKey(prefix, entry.Key), entry.Value); + return; + } + case IList l: + { + for (var i = 0; i < l.Count; i++) + AddToBackingStore(backingStore, MakeArrayKey(prefix, i), l[i]); + return; + } + default: + backingStore.Add(prefix, value); + break; + } + } + + private static object GetDeserializedObject(ControllerContext controllerContext) + { + var request = controllerContext.HttpContext.Request; + if (!request.ContentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)) + return null; + + string bodyText; + using (var reader = new StreamReader(request.InputStream)) + bodyText = reader.ReadToEnd(); + + if (string.IsNullOrEmpty(bodyText)) + return null; + + object jsonData; + using (var reader = new StringReader(bodyText)) + using (var jsonTextReader = new JsonTextReader(reader)) + { + jsonTextReader.Read(); + if (jsonTextReader.TokenType == JsonToken.StartArray) + jsonData = Serializer.Deserialize>(jsonTextReader); + else + jsonData = Serializer.Deserialize(jsonTextReader); + } + + return jsonData; + } + + public override IValueProvider GetValueProvider(ControllerContext controllerContext) + { + if (controllerContext == null) + throw new ArgumentNullException(nameof(controllerContext)); + + var jsonData = GetDeserializedObject(controllerContext); + if (jsonData == null) return null; + + var backingStore = new Dictionary(StringComparer.OrdinalIgnoreCase); + var backingStoreWrapper = new EntryLimitedDictionary(backingStore); + AddToBackingStore(backingStoreWrapper, string.Empty, jsonData); + return new DictionaryValueProvider(backingStore, CultureInfo.CurrentCulture); + } + + private static string MakeArrayKey(string prefix, int index) => + prefix + "[" + index.ToString(CultureInfo.InvariantCulture) + "]"; + + private static string MakePropertyKey(string prefix, string propertyName) => + string.IsNullOrEmpty(prefix) ? propertyName : prefix + "." + propertyName; + + private class EntryLimitedDictionary + { + private static readonly int MaximumDepth = GetMaximumDepth(); + private readonly IDictionary _innerDictionary; + private int _itemCount; + + public EntryLimitedDictionary(IDictionary innerDictionary) => + _innerDictionary = innerDictionary; + + public void Add(string key, object value) + { + if (++_itemCount > MaximumDepth) + throw new InvalidOperationException("Request too large"); + + _innerDictionary.Add(key, value); + } + + private static int GetMaximumDepth() + { + var appSettings = System.Configuration.ConfigurationManager.AppSettings; + var valueArray = appSettings?.GetValues("aspnet:MaxJsonDeserializerMembers"); + if (valueArray == null || valueArray.Length <= 0) return 1000; + return int.TryParse(valueArray[0], out var result) ? result : 1000; + } + } + } +} diff --git a/sample/AspNetFullFrameworkSampleApp/Mvc/StreamResult.cs b/sample/AspNetFullFrameworkSampleApp/Mvc/StreamResult.cs new file mode 100644 index 000000000..f5c0727d2 --- /dev/null +++ b/sample/AspNetFullFrameworkSampleApp/Mvc/StreamResult.cs @@ -0,0 +1,45 @@ +// Licensed to Elasticsearch B.V under +// one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO; +using System.Web.Mvc; + +namespace AspNetFullFrameworkSampleApp.Mvc +{ + public class StreamResult : ActionResult + { + private const int BufferSize = 4096; + private readonly Stream _stream; + private readonly string _contentType; + private readonly int _statusCode; + + public StreamResult(Stream stream, string contentType, int statusCode = 200) + { + _stream = stream; + _contentType = contentType; + _statusCode = statusCode; + } + + public override void ExecuteResult(ControllerContext context) + { + var response = context.RequestContext.HttpContext.Response; + response.StatusCode = _statusCode; + response.ContentType = _contentType; + var outputStream = response.OutputStream; + using (_stream) + { + var buffer = new byte[BufferSize]; + while (true) + { + var count = _stream.Read(buffer, 0, BufferSize); + if (count != 0) + outputStream.Write(buffer, 0, count); + else + break; + } + } + } + } +} diff --git a/sample/AspNetFullFrameworkSampleApp/Views/Database/Create.cshtml b/sample/AspNetFullFrameworkSampleApp/Views/Database/Create.cshtml new file mode 100644 index 000000000..aad9eb9f8 --- /dev/null +++ b/sample/AspNetFullFrameworkSampleApp/Views/Database/Create.cshtml @@ -0,0 +1,18 @@ +@model CreateSampleDataViewModel + +@using (Html.BeginForm("Create", "Database", FormMethod.Post, new { id = "createForm", @class = "form-horizontal", role = "form" })) +{ + @Html.AntiForgeryToken() +

Add a new sample

+
+ @Html.ValidationSummary(true, "", new { @class = "text-danger" }) +
+ @Html.Hidden("samples.Index", "index") + @Html.Label("samples[index].Name") + @Html.TextBox("samples[index].Name", "", new { @class = "form-control" }) + @Html.ValidationMessage("samples[index].Name", "", new { @class = "text-danger" }) +
+
+ +
+} \ No newline at end of file diff --git a/sample/AspNetFullFrameworkSampleApp/Views/Database/Index.cshtml b/sample/AspNetFullFrameworkSampleApp/Views/Database/Index.cshtml new file mode 100644 index 000000000..be9914f53 --- /dev/null +++ b/sample/AspNetFullFrameworkSampleApp/Views/Database/Index.cshtml @@ -0,0 +1,26 @@ +@model IEnumerable + +@if (!Model.Any()) +{ +

No samples in the database.

+} +else +{ + + + + + + + + + @foreach (var sample in Model) + { + + + + + } + +
IdName
@sample.Id@sample.Name
+} \ No newline at end of file diff --git a/src/Elastic.Apm.AspNetFullFramework/ElasticApmModule.cs b/src/Elastic.Apm.AspNetFullFramework/ElasticApmModule.cs index 19d435892..38958e812 100644 --- a/src/Elastic.Apm.AspNetFullFramework/ElasticApmModule.cs +++ b/src/Elastic.Apm.AspNetFullFramework/ElasticApmModule.cs @@ -24,6 +24,7 @@ internal static class OpenIdClaimTypes internal const string Email = "email"; internal const string UserId = "sub"; } + public class ElasticApmModule : IHttpModule { private static bool _isCaptureHeadersEnabled; @@ -36,10 +37,6 @@ public class ElasticApmModule : IHttpModule // ReSharper disable once ImpureMethodCallOnReadonlyValueField public ElasticApmModule() => _dbgInstanceName = DbgInstanceNameGenerator.Generate($"{nameof(ElasticApmModule)}.#"); - // We can store current transaction because each IHttpModule is used for at most one request at a time - // For example see https://bytes.com/topic/asp-net/answers/324305-httpmodule-multithreading-request-response-corelation - private ITransaction _currentTransaction; - private HttpApplication _httpApp; private IApmLogger _logger; @@ -126,14 +123,17 @@ private void ProcessBeginRequest(object eventSender) if (soapAction != null) transactionName += $" {soapAction}"; var distributedTracingData = ExtractIncomingDistributedTracingData(httpRequest); + ITransaction transaction; + if (distributedTracingData != null) { _logger.Debug() ?.Log( "Incoming request with {TraceParentHeaderName} header. DistributedTracingData: {DistributedTracingData} - continuing trace", DistributedTracing.TraceContext.TraceParentHeaderNamePrefixed, distributedTracingData); + // we set ignoreActivity to true to avoid the HttpContext W3C DiagnosticSource issue (see https://github.com/elastic/apm-agent-dotnet/issues/867#issuecomment-650170150) - _currentTransaction = Agent.Instance.Tracer.StartTransaction(transactionName, ApiConstants.TypeRequest, distributedTracingData, true); + transaction = Agent.Instance.Tracer.StartTransaction(transactionName, ApiConstants.TypeRequest, distributedTracingData, true); } else { @@ -141,10 +141,10 @@ private void ProcessBeginRequest(object eventSender) ?.Log("Incoming request doesn't have valid incoming distributed tracing data - starting trace with new trace ID"); // we set ignoreActivity to true to avoid the HttpContext W3C DiagnosticSource issue(see https://github.com/elastic/apm-agent-dotnet/issues/867#issuecomment-650170150) - _currentTransaction = Agent.Instance.Tracer.StartTransaction(transactionName, ApiConstants.TypeRequest, ignoreActivity: true); + transaction = Agent.Instance.Tracer.StartTransaction(transactionName, ApiConstants.TypeRequest, ignoreActivity: true); } - if (_currentTransaction.IsSampled) FillSampledTransactionContextRequest(httpRequest, _currentTransaction); + if (transaction.IsSampled) FillSampledTransactionContextRequest(httpRequest, transaction); } /// @@ -242,11 +242,11 @@ private void ProcessEndRequest(object eventSender) var httpApp = (HttpApplication)eventSender; var httpCtx = httpApp.Context; var httpResponse = httpCtx.Response; - var transaction = _currentTransaction; + var transaction = Agent.Instance.Tracer.CurrentTransaction; if (transaction == null) return; - SendErrorEventIfPresent(httpCtx); + SendErrorEventIfPresent(httpCtx, transaction); // update the transaction name based on route values, if applicable if (transaction is Transaction t && !t.HasCustomName) @@ -280,7 +280,7 @@ private void ProcessEndRequest(object eventSender) _logger?.Trace()?.Log("Calculating transaction name based on route data"); var name = Transaction.GetNameFromRouteContext(routeData); - if (!string.IsNullOrWhiteSpace(name)) _currentTransaction.Name = $"{httpCtx.Request.HttpMethod} {name}"; + if (!string.IsNullOrWhiteSpace(name)) transaction.Name = $"{httpCtx.Request.HttpMethod} {name}"; } else { @@ -292,27 +292,27 @@ private void ProcessEndRequest(object eventSender) } } - _currentTransaction.Result = Transaction.StatusCodeToResult("HTTP", httpResponse.StatusCode); + transaction.Result = Transaction.StatusCodeToResult("HTTP", httpResponse.StatusCode); if (httpResponse.StatusCode >= 500) - _currentTransaction.Outcome = Outcome.Failure; + transaction.Outcome = Outcome.Failure; else - _currentTransaction.Outcome = Outcome.Success; + transaction.Outcome = Outcome.Success; - if (_currentTransaction.IsSampled) + if (transaction.IsSampled) { - FillSampledTransactionContextResponse(httpResponse, _currentTransaction); - FillSampledTransactionContextUser(httpCtx, _currentTransaction); + FillSampledTransactionContextResponse(httpResponse, transaction); + FillSampledTransactionContextUser(httpCtx, transaction); } - _currentTransaction.End(); - _currentTransaction = null; + transaction.End(); + transaction = null; } - private void SendErrorEventIfPresent(HttpContext httpCtx) + private void SendErrorEventIfPresent(HttpContext httpCtx, ITransaction transaction) { var lastError = httpCtx.Server.GetLastError(); - if (lastError != null) _currentTransaction.CaptureException(lastError); + if (lastError != null) transaction.CaptureException(lastError); } private static void FillSampledTransactionContextResponse(HttpResponse httpResponse, ITransaction transaction) => @@ -420,7 +420,13 @@ private static AgentComponents BuildAgentComponents(string dbgInstanceName) var reader = ConfigHelper.CreateReader(rootLogger) ?? new FullFrameworkConfigReader(rootLogger); - var agentComponents = new AgentComponents(rootLogger, reader); + var agentComponents = new AgentComponents( + rootLogger, + reader, + null, + null, + new HttpContextCurrentExecutionSegmentsContainer(), + null); var aspNetVersion = FindAspNetVersion(scopedLogger); diff --git a/src/Elastic.Apm.AspNetFullFramework/HttpContextCurrentExecutionSegmentsContainer.cs b/src/Elastic.Apm.AspNetFullFramework/HttpContextCurrentExecutionSegmentsContainer.cs new file mode 100644 index 000000000..5f3172b7a --- /dev/null +++ b/src/Elastic.Apm.AspNetFullFramework/HttpContextCurrentExecutionSegmentsContainer.cs @@ -0,0 +1,48 @@ +// Licensed to Elasticsearch B.V under +// one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Threading; +using System.Web; +using Elastic.Apm.Model; + +namespace Elastic.Apm.AspNetFullFramework +{ + /// + /// An that stores the current transaction + /// and current span in both async local storage and the current + /// + internal sealed class HttpContextCurrentExecutionSegmentsContainer : ICurrentExecutionSegmentsContainer + { + private readonly AsyncLocal _currentSpan = new AsyncLocal(); + private readonly AsyncLocal _currentTransaction = new AsyncLocal(); + + private const string CurrentSpanKey = "Elastic.Apm.Agent.CurrentSpan"; + private const string CurrentTransactionKey = "Elastic.Apm.Agent.CurrentTransaction"; + + public Span CurrentSpan + { + get => _currentSpan.Value ?? HttpContext.Current?.Items[CurrentSpanKey] as Span; + set + { + _currentSpan.Value = value; + var httpContext = HttpContext.Current; + if (httpContext != null) + httpContext.Items[CurrentSpanKey] = value; + } + } + + public Transaction CurrentTransaction + { + get => _currentTransaction.Value ?? HttpContext.Current?.Items[CurrentTransactionKey] as Transaction; + set + { + _currentTransaction.Value = value; + var httpContext = HttpContext.Current; + if (httpContext != null) + httpContext.Items[CurrentTransactionKey] = value; + } + } + } +} diff --git a/src/Elastic.Apm/ICurrentExecutionSegmentsContainer.cs b/src/Elastic.Apm/ICurrentExecutionSegmentsContainer.cs index 545737356..ef4f8f975 100644 --- a/src/Elastic.Apm/ICurrentExecutionSegmentsContainer.cs +++ b/src/Elastic.Apm/ICurrentExecutionSegmentsContainer.cs @@ -8,7 +8,14 @@ namespace Elastic.Apm { internal interface ICurrentExecutionSegmentsContainer { + /// + /// Gets or sets the current span + /// Span CurrentSpan { get; set; } + + /// + /// Gets or sets the current transaction + /// Transaction CurrentTransaction { get; set; } } } diff --git a/test/Elastic.Apm.AspNetFullFramework.Tests/Elastic.Apm.AspNetFullFramework.Tests.csproj b/test/Elastic.Apm.AspNetFullFramework.Tests/Elastic.Apm.AspNetFullFramework.Tests.csproj index d4c1706d9..5c0c72bf4 100644 --- a/test/Elastic.Apm.AspNetFullFramework.Tests/Elastic.Apm.AspNetFullFramework.Tests.csproj +++ b/test/Elastic.Apm.AspNetFullFramework.Tests/Elastic.Apm.AspNetFullFramework.Tests.csproj @@ -28,7 +28,7 @@ - + TargetFramework=net461 diff --git a/test/Elastic.Apm.AspNetFullFramework.Tests/HttpContextCurrentExecutionSegmentsContainerTests.cs b/test/Elastic.Apm.AspNetFullFramework.Tests/HttpContextCurrentExecutionSegmentsContainerTests.cs new file mode 100644 index 000000000..e7db33d55 --- /dev/null +++ b/test/Elastic.Apm.AspNetFullFramework.Tests/HttpContextCurrentExecutionSegmentsContainerTests.cs @@ -0,0 +1,158 @@ +// Licensed to Elasticsearch B.V under +// one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using AspNetFullFrameworkSampleApp.Models; +using FluentAssertions; +using Newtonsoft.Json; +using Xunit; +using Xunit.Abstractions; + +namespace Elastic.Apm.AspNetFullFramework.Tests +{ + [Collection(Consts.AspNetFullFrameworkTestsCollection)] + public class HttpContextCurrentExecutionSegmentsContainerTests : TestsBase + { + public HttpContextCurrentExecutionSegmentsContainerTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) + { + } + + [AspNetFullFrameworkFact] + public async Task Transaction_And_Spans_Captured_When_Large_Request() + { + var samples = Enumerable.Range(1, 1_000) + .Select(i => new CreateSampleDataViewModel { Name = $"Sample {i}" }); + + var json = JsonConvert.SerializeObject(samples); + var bytes = Encoding.UTF8.GetByteCount(json); + + // larger than 20Kb + bytes.Should().BeGreaterThan(20_000); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var client = new HttpClient(); + var bulkSamplesUri = Consts.SampleApp.CreateUrl("/Database/Bulk"); + var response = await client.PostAsync(bulkSamplesUri, content).ConfigureAwait(false); + + var responseContent = await response.Content.ReadAsStringAsync(); + response.IsSuccessStatusCode.Should().BeTrue(responseContent); + + await WaitAndCustomVerifyReceivedData(received => + { + received.Transactions.Count.Should().Be(1); + var transaction = received.Transactions.Single(); + + transaction.SpanCount.Started.Should().Be(500); + transaction.SpanCount.Dropped.Should().Be(501); + received.Spans.Count.Should().Be(500); + }); + } + + [AspNetFullFrameworkFact] + public async Task Transaction_And_Spans_Captured_When_Controller_Action_Makes_Async_Http_Call() + { + var count = 100; + var content = new StringContent($"{{\"count\":{count}}}", Encoding.UTF8, "application/json"); + + var client = new HttpClient(); + var bulkSamplesUri = Consts.SampleApp.CreateUrl("/Database/Generate"); + var response = await client.PostAsync(bulkSamplesUri, content).ConfigureAwait(false); + + var responseContent = await response.Content.ReadAsStringAsync(); + response.IsSuccessStatusCode.Should().BeTrue(responseContent); + + await WaitAndCustomVerifyReceivedData(received => + { + received.Transactions.Count.Should().Be(2); + var transactions = received.Transactions + .OrderByDescending(t => t.Timestamp) + .ToList(); + + var firstTransaction = transactions.First(); + firstTransaction.Name.Should().EndWith("Bulk"); + firstTransaction.SpanCount.Started.Should().Be(100); + + var secondTransaction = transactions.Last(); + secondTransaction.Name.Should().EndWith("Generate"); + secondTransaction.SpanCount.Started.Should().Be(3); + + received.Spans.Count.Should().Be(103); + }); + } + + [AspNetFullFrameworkFact] + public async Task Transaction_And_Spans_Captured_When_Multiple_Concurrent_Requests() + { + static HttpRequestMessage CreateMessage(int i) + { + var message = new HttpRequestMessage(HttpMethod.Get, Consts.SampleApp.CreateUrl("/Home/Contact")); + message.Headers.Add("X-HttpRequest", i.ToString(CultureInfo.InvariantCulture)); + return message; + } + + var count = 9; + var messages = Enumerable.Range(1, count) + .Select(i => CreateMessage(i)) + .ToList(); + + // infinite timespan + var client = new HttpClient { Timeout = TimeSpan.FromMilliseconds(-1) }; + + var tasks = new List>(messages.Count); + foreach (var message in messages) + tasks.Add(client.SendAsync(message)); + + await Task.WhenAll(tasks).ConfigureAwait(false); + + for (var index = 0; index < tasks.Count; index++) + { + var task = tasks[index]; + task.IsCompletedSuccessfully.Should().BeTrue(); + var response = task.Result; + var responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + response.IsSuccessStatusCode.Should().BeTrue($"response {index}: {responseContent}"); + } + + await WaitAndCustomVerifyReceivedData(received => + { + received.Transactions.Count.Should().Be(count * 2); + + var contactTransactions = received.Transactions + .Where(t => t.Name == "GET Home/Contact") + .ToList(); + + contactTransactions.Should().HaveCount(count); + + var aboutTransactions = received.Transactions + .Where(t => t.Name == "GET Home/About") + .ToList(); + + aboutTransactions.Should().HaveCount(count); + + // assert that each aboutTransaction is a child of a span associated with a contactTransaction + foreach (var contactTransaction in contactTransactions) + { + contactTransaction.ParentId.Should().BeNull(); + var spans = received.Spans.Where(s => s.TransactionId == contactTransaction.Id) + .ToList(); + + spans.Should().HaveCount(2); + + var localHostSpan = spans.SingleOrDefault(s => s.Name == "GET localhost"); + localHostSpan.Should().NotBeNull(); + + var aboutTransaction = aboutTransactions.SingleOrDefault(t => t.ParentId == localHostSpan.Id); + aboutTransaction.Should().NotBeNull(); + } + }).ConfigureAwait(false); + } + } +}