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); + } + } +}